package main
import (
"context"
"embed"
"encoding/base64"
"errors"
"flag"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/integrationtesting"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/middleware"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/route"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/service/dbexport"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
//go:embed static templates
var f embed.FS
// Version is set at build time via -ldflags
var Version = "dev"
func exists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func main() {
// Command-line flags
generateTestDataOnly := flag.Bool("generate-testdata-only", false, "Generate test data and exit without starting the server")
dbDriver := flag.String("db-driver", "", "Database driver (postgres, mysql, sqlite)")
flag.Parse()
// Initialize i18n
if err := i18n.Init(); err != nil {
panic(fmt.Sprintf("Failed to initialize i18n: %v", err))
}
funcs := map[string]any{
"map": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, errors.New("misaligned map")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
"t": func(lang, key string) string {
return i18n.Translate(lang, key)
},
// tp translates with plural support based on count
"tp": func(lang, key string, count int) string {
return i18n.TranslatePlural(lang, key, count)
},
"version": func() string {
return Version
},
// isDatePast checks if a date is in the past
"isDatePast": func(t *time.Time) bool {
if t == nil {
return false
}
return t.Before(time.Now())
},
// isDateSoon checks if a date is within the next 30 days
"isDateSoon": func(t *time.Time) bool {
if t == nil {
return false
}
now := time.Now()
thirtyDaysFromNow := now.AddDate(0, 0, 30)
return t.After(now) && t.Before(thirtyDaysFromNow)
},
// mod returns a % b (modulo operation)
"mod": func(a, b int) int {
return a % b
},
// safeJS marks a string as safe JavaScript (bypasses HTML escaping)
"safeJS": func(s string) template.JS {
return template.JS(s)
},
// deref dereferences a pointer to uint for template comparisons
"deref": func(p *uint) uint {
if p == nil {
return 0
}
return *p
},
// truncate returns the first n characters of a string, appending "..." if truncated
"truncate": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
// add returns the sum of two integers (for template arithmetic)
"add": func(a, b int) int {
return a + b
},
// formatBytes formats a file size in bytes to a human-readable string
"formatBytes": func(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
)
switch {
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
},
}
r := gin.Default()
tok, _ := exists("templates")
sok, _ := exists("static")
if gin.IsDebugging() && tok && sok {
logger.Debug("loading templates from disk", "mode", "development")
r.SetFuncMap(funcs)
r.LoadHTMLGlob("templates/**/*")
r.Static("/static", "static")
} else {
logger.Debug("loading templates from embedded FS", "mode", "production")
tpl := template.Must(template.New("").Funcs(funcs).ParseFS(f, "templates/**/*"))
r.SetHTMLTemplate(tpl)
staticFS, _ := fs.Sub(f, "static")
r.StaticFS("/static", http.FS(staticFS))
}
// Load .env file if it exists, otherwise use environment variables directly
if envExists, _ := exists(".env"); envExists {
if err := godotenv.Load(); err != nil {
logger.Warn("error loading .env file", "error", err)
}
}
// Initialize logger after .env is loaded (LOG_LEVEL may be set there)
logger.Init()
// Refuse to start without APP_SECRET when serving requests. JWT
// signing, bot-protection HMAC and CSRF read it; running with an
// empty value would silently fall back to weak/predictable keys.
//
// --generate-testdata-only is the admin tool that builds the
// testdata SQL fixture in CI. It writes seed users + their bcrypt
// hashes and exits without ever serving a request, so it has no
// need for APP_SECRET. Gating that path on a production secret
// breaks the regenerate-testdata CI job for every PR.
if !*generateTestDataOnly {
if _, err := util.RequireEnvSecret("APP_SECRET"); err != nil {
logger.Error("startup blocked", "error", err)
os.Exit(1)
}
}
// Database configuration - command-line flag overrides environment variable
dbDriverValue := os.Getenv("DB_DRIVER")
if *dbDriver != "" {
dbDriverValue = *dbDriver
}
config := model.Config{
Driver: dbDriverValue,
Host: os.Getenv("DB_HOST"),
Port: os.Getenv("DB_PORT"),
User: os.Getenv("DB_USER"),
Password: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"),
SSLMode: os.Getenv("DB_SSLMODE"),
}
// Forgejo configuration for feedback/issue creation
forgejoConfig := service.ForgejoConfig{
URL: os.Getenv("FORGEJO_URL"),
Token: os.Getenv("FORGEJO_TOKEN"),
Repo: os.Getenv("FORGEJO_REPO"),
}
model.InitDB(config)
// Stamp the running version into the dbexport package so dumps carry
// it in the header.
dbexport.BuildVersion = Version
// Initialize Forgejo service if configured
if forgejoConfig.URL != "" && forgejoConfig.Token != "" && forgejoConfig.Repo != "" {
if err := service.InitForgejoService(forgejoConfig); err != nil {
logger.Warn("failed to initialize Forgejo service", "error", err)
} else {
logger.Info("Forgejo service initialized")
}
} else {
logger.Debug("Forgejo service not configured", "reason", "missing environment variables")
}
r.Use(middleware.CookieDefaults())
// CSRF protection for cookie-authenticated state-changing routes.
// Reuses APP_SECRET as the authenticator key — already validated as
// non-empty above. base64-decoded to match the JWT key convention.
csrfKey, err := base64.StdEncoding.DecodeString(os.Getenv("APP_SECRET"))
if err != nil || len(csrfKey) == 0 {
// Fall back to the raw bytes if APP_SECRET wasn't base64.
// gorilla/csrf only requires non-empty input.
csrfKey = []byte(os.Getenv("APP_SECRET"))
}
r.Use(middleware.CSRF(csrfKey))
r.Use(middleware.LanguageDetection())
r.Use(middleware.IsAuthorized())
r.Use(middleware.ForcePasswordChange())
// Inject version into context for all requests
r.Use(func(c *gin.Context) {
c.Set("version", Version)
c.Next()
})
route.AuthRoutes(r)
route.MainRoutes(r)
route.APIRoutes(r) // JSON API for JS frontend and mobile apps
route.IntranetAPIRoutes(r) // Intranet data sync API (token-authenticated)
// App Testing gets default users and default settings, not for usage in production!
if gin.IsDebugging() {
integrationtesting.TestingInit()
}
// Exit early if we only want to generate test data
if *generateTestDataOnly {
logger.Info("test data generation complete, exiting")
return
}
// Start midnight sync scheduler
schedulerCtx, schedulerCancel := context.WithCancel(context.Background())
defer schedulerCancel()
service.StartSyncScheduler(schedulerCtx, model.DB)
service.StartReminderScheduler(schedulerCtx, model.DB)
bindAddress := fmt.Sprintf(
"%s:%s",
os.Getenv("USE_BIND"),
os.Getenv("USE_PORT"),
)
SetupSecurityHeaders(r, os.Getenv("EXPECTED_HOST"))
// setup middleware
// Build an explicit http.Server so we can shut it down gracefully
// on SIGINT/SIGTERM. Audit #464 flagged that r.Run() / r.RunTLS()
// (which both wrap http.Server.ListenAndServe with no shutdown
// hook) kill in-flight requests on signal — notable for the
// long-running intranet sync and large attachment uploads.
srv := &http.Server{
Addr: bindAddress,
Handler: r,
}
// Run the listener in a goroutine so the main goroutine can wait
// for a shutdown signal in parallel.
use_tls := os.Getenv("USE_TLS")
serverErr := make(chan error, 1)
go func() {
var err error
if use_tls == "" || use_tls == "false" {
err = srv.ListenAndServe()
} else {
err = srv.ListenAndServeTLS(
os.Getenv("TLS_CERT"),
os.Getenv("TLS_KEY"),
)
}
if err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
logger.Info("server listening", "addr", bindAddress, "tls", use_tls != "" && use_tls != "false")
// Wait for an interrupt signal or a listener error.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case sig := <-stop:
logger.Info("shutdown signal received", "signal", sig.String())
case err := <-serverErr:
logger.Error("server crashed", "error", err)
}
// Cancel the scheduler context immediately so the background
// schedulers stop scheduling new work. In-flight tick work still
// runs to completion under its own short scope.
schedulerCancel()
// Give in-flight HTTP requests up to 30s to complete. 30s is a
// pragmatic ceiling for the longest legitimate request shapes
// (intranet sync, multi-MB attachment uploads); SIGKILL from the
// orchestrator usually arrives at the 30–60s mark, so anything
// longer is "we're going to be killed anyway."
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed; forcing close", "error", err)
_ = srv.Close()
} else {
logger.Info("graceful shutdown complete")
}
}
func SetupSecurityHeaders(router *gin.Engine, expectedHost string) {
// Setup Security Headers
router.Use(func(c *gin.Context) {
// Skip the host-header check for liveness/readiness probes.
// Orchestrators probe via the pod IP / loopback, which never
// matches the public EXPECTED_HOST. Without this skip every
// probe 400s and the pod gets restarted by Kubernetes-style
// liveness checks.
path := c.Request.URL.Path
if path != "/healthz" && path != "/readyz" {
if c.Request.Host != expectedHost {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid host header"})
return
}
}
c.Header("X-Frame-Options", "DENY")
c.Header("Content-Security-Policy", "default-src 'self'; connect-src *; font-src *; script-src-elem * 'unsafe-inline'; img-src * data:; style-src * 'unsafe-inline';")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
c.Header("Referrer-Policy", "strict-origin")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Permissions-Policy", "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()")
c.Next()
})
}
package main
import (
"fmt"
"os"
"wippidu_app_backend/internal/integrationtesting"
"wippidu_app_backend/internal/model"
)
func main() {
// Initialize database
cfg := model.Config{
Driver: "sqlite",
Host: "",
Port: "",
User: "",
Password: "",
DBName: "",
SSLMode: "",
}
model.InitDB(cfg)
fmt.Println("=== Starting Test Data Initialization ===")
// Set environment variable to enable test data
os.Setenv("APP_TESTING", "true")
// Initialize test data
integrationtesting.TestingInit()
fmt.Println("\n=== Test Data Initialization Complete ===")
fmt.Println("\nYou can now log in with:")
fmt.Println(" Parent (3 children): emma.mueller@wippidu.app / password123")
fmt.Println(" Parent (2 children): liam.schmidt@wippidu.app / password123")
fmt.Println(" Employee (The Bees): teacher.bees@wippidu.app / teacher123")
fmt.Println(" Employee (All Alpha): teacher.alpha@wippidu.app / teacher123")
fmt.Println(" Admin: admin@wippidu.app / adminsecret")
}
package botprotection
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"time"
)
// ChallengeToken represents a challenge token for bot protection
type ChallengeToken struct {
Nonce string `json:"n"` // Random nonce
Timestamp int64 `json:"t"` // Unix timestamp when token was created
Signature string `json:"sig"` // HMAC-SHA256 signature
}
// ChallengeResponse represents the response from the client
type ChallengeResponse struct {
Nonce string `json:"n"` // Original nonce
Timestamp int64 `json:"t"` // Original timestamp
Signature string `json:"sig"` // Original signature
TimeSpent int64 `json:"ts"` // Time spent on form in milliseconds
}
var (
ErrInvalidTokenFormat = errors.New("invalid challenge token format")
ErrInvalidSignature = errors.New("invalid challenge token signature")
ErrTokenExpired = errors.New("challenge token has expired")
ErrTokenFromFuture = errors.New("challenge token timestamp is in the future")
ErrFormFilledTooFast = errors.New("form was filled too quickly")
ErrMissingChallengeToken = errors.New("missing challenge token (JavaScript required)")
)
// GenerateChallengeToken creates a new challenge token
func GenerateChallengeToken() ChallengeToken {
nonce := generateRandomString(16)
timestamp := time.Now().Unix()
signature := signToken(nonce, timestamp)
slog.Debug("botprotection: generated challenge token",
"nonce", nonce,
"timestamp", timestamp,
"signaturePrefix", signature[:16]+"...",
"secretHash", hashSecret(getSecret()),
)
return ChallengeToken{
Nonce: nonce,
Timestamp: timestamp,
Signature: signature,
}
}
// ValidateChallengeToken validates a challenge response from the client
// minAge: minimum time that must have elapsed since token creation
// maxAge: maximum time allowed since token creation
func ValidateChallengeToken(tokenJSON string, minAge, maxAge time.Duration) error {
slog.Debug("botprotection: validating token",
"tokenJSONLength", len(tokenJSON),
"tokenJSON", tokenJSON,
)
if tokenJSON == "" {
slog.Debug("botprotection: validation failed - missing token")
return ErrMissingChallengeToken
}
var response ChallengeResponse
if err := json.Unmarshal([]byte(tokenJSON), &response); err != nil {
slog.Debug("botprotection: validation failed - invalid token format",
"error", err.Error(),
"tokenJSON", tokenJSON,
)
return ErrInvalidTokenFormat
}
// Verify signature
expectedSig := signToken(response.Nonce, response.Timestamp)
slog.Debug("botprotection: validating signature",
"nonce", response.Nonce,
"timestamp", response.Timestamp,
"receivedSigPrefix", response.Signature[:min(16, len(response.Signature))]+"...",
"expectedSigPrefix", expectedSig[:16]+"...",
"signaturesMatch", response.Signature == expectedSig,
"secretHash", hashSecret(getSecret()),
)
if response.Signature != expectedSig {
return ErrInvalidSignature
}
// Verify timestamp is not in the future
tokenTime := time.Unix(response.Timestamp, 0)
now := time.Now()
if tokenTime.After(now.Add(1 * time.Minute)) { // Allow 1 minute clock skew
return ErrTokenFromFuture
}
// Verify token is not expired
if now.Sub(tokenTime) > maxAge {
return ErrTokenExpired
}
// Verify minimum time elapsed (from client-reported time spent)
if response.TimeSpent < int64(minAge.Milliseconds()) {
return ErrFormFilledTooFast
}
return nil
}
// signToken creates an HMAC-SHA256 signature for the token
func signToken(nonce string, timestamp int64) string {
secret := getSecret()
message := fmt.Sprintf("%s:%d", nonce, timestamp)
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
return hex.EncodeToString(h.Sum(nil))
}
// generateRandomString generates a cryptographically secure random string
func generateRandomString(length int) string {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
// Fallback to timestamp-based if crypto/rand fails
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(bytes)[:length]
}
// getSecret returns the secret key for HMAC signing.
//
// APP_SECRET is required at server startup (see main.go); by the time
// any request reaches this function it is non-empty. The previous
// development fallback to a hardcoded string is intentionally gone —
// a missing secret should be a loud startup failure, not a silent
// downgrade to a predictable key.
func getSecret() string {
return os.Getenv("APP_SECRET")
}
// ToJSON converts the challenge token to a JSON string for embedding in HTML
func (t ChallengeToken) ToJSON() string {
bytes, _ := json.Marshal(t)
return string(bytes)
}
// hashSecret creates a short hash of the secret for debugging (to identify if secrets differ)
func hashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])[:8]
}
package botprotection
import (
"sync"
"time"
)
// RateLimitConfig holds the configuration for the rate limiter
type RateLimitConfig struct {
WindowDuration time.Duration // Time window for counting attempts
MaxAttempts int // Maximum attempts allowed in window
BlockDuration time.Duration // Initial block duration
MaxBlockDuration time.Duration // Maximum block duration (cap for exponential backoff)
}
// DefaultRateLimitConfig returns the default rate limit configuration
func DefaultRateLimitConfig() RateLimitConfig {
return RateLimitConfig{
WindowDuration: 15 * time.Minute,
MaxAttempts: 5,
BlockDuration: 5 * time.Minute,
MaxBlockDuration: 24 * time.Hour,
}
}
// RateLimitEntry tracks rate limit state for a single IP
type RateLimitEntry struct {
IP string
Attempts int
WindowStart time.Time
BlockedUntil *time.Time
BlockCount int // Number of times blocked (for exponential backoff)
LastAttemptAt time.Time
}
// RateLimiter implements an in-memory sliding window rate limiter
type RateLimiter struct {
mu sync.RWMutex
entries map[string]*RateLimitEntry
config RateLimitConfig
}
// Global rate limiter instance
var (
globalRateLimiter *RateLimiter
rateLimiterOnce sync.Once
)
// GetRateLimiter returns the global rate limiter instance
func GetRateLimiter() *RateLimiter {
rateLimiterOnce.Do(func() {
globalRateLimiter = NewRateLimiter(DefaultRateLimitConfig())
})
return globalRateLimiter
}
// NewRateLimiter creates a new rate limiter with the given config
func NewRateLimiter(config RateLimitConfig) *RateLimiter {
rl := &RateLimiter{
entries: make(map[string]*RateLimitEntry),
config: config,
}
// Start background cleanup goroutine
go rl.cleanup()
return rl
}
// ShouldAllow checks if the given IP is allowed to make a request
// Returns (allowed, retryAfter) where retryAfter is the duration until the block expires
func (r *RateLimiter) ShouldAllow(ip string) (bool, time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
entry, exists := r.entries[ip]
if !exists {
// First request from this IP
r.entries[ip] = &RateLimitEntry{
IP: ip,
Attempts: 1,
WindowStart: now,
LastAttemptAt: now,
}
return true, 0
}
// Check if currently blocked
if entry.BlockedUntil != nil && now.Before(*entry.BlockedUntil) {
return false, entry.BlockedUntil.Sub(now)
}
// Clear block if it has expired
if entry.BlockedUntil != nil && now.After(*entry.BlockedUntil) {
entry.BlockedUntil = nil
}
// Check if window has expired - reset counter
if now.Sub(entry.WindowStart) > r.config.WindowDuration {
entry.Attempts = 1
entry.WindowStart = now
entry.LastAttemptAt = now
return true, 0
}
// Increment attempt counter
entry.Attempts++
entry.LastAttemptAt = now
// Check if max attempts exceeded
if entry.Attempts > r.config.MaxAttempts {
// Calculate exponential backoff
blockDuration := r.calculateBackoff(entry.BlockCount)
blockedUntil := now.Add(blockDuration)
entry.BlockedUntil = &blockedUntil
entry.BlockCount++
return false, blockDuration
}
return true, 0
}
// RecordFailure records a failed attempt for the given IP
// This can be used to record failures that should count against rate limits
// (e.g., failed challenge validation)
func (r *RateLimiter) RecordFailure(ip string, reason string) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
entry, exists := r.entries[ip]
if !exists {
r.entries[ip] = &RateLimitEntry{
IP: ip,
Attempts: 1,
WindowStart: now,
LastAttemptAt: now,
}
return
}
// If window has expired, start a new one
if now.Sub(entry.WindowStart) > r.config.WindowDuration {
entry.Attempts = 1
entry.WindowStart = now
} else {
entry.Attempts++
}
entry.LastAttemptAt = now
// Check if this failure triggers a block
if entry.Attempts > r.config.MaxAttempts && entry.BlockedUntil == nil {
blockDuration := r.calculateBackoff(entry.BlockCount)
blockedUntil := now.Add(blockDuration)
entry.BlockedUntil = &blockedUntil
entry.BlockCount++
}
}
// calculateBackoff returns the block duration using exponential backoff
func (r *RateLimiter) calculateBackoff(blockCount int) time.Duration {
// Exponential backoff: base * 2^blockCount
duration := r.config.BlockDuration
for i := 0; i < blockCount; i++ {
duration *= 2
if duration > r.config.MaxBlockDuration {
return r.config.MaxBlockDuration
}
}
return duration
}
// cleanup runs periodically to remove expired entries
func (r *RateLimiter) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
r.cleanupExpiredEntries()
}
}
// cleanupExpiredEntries removes entries that are no longer needed
func (r *RateLimiter) cleanupExpiredEntries() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
// Remove entries that haven't been accessed in the last hour
// and are not currently blocked
expireThreshold := now.Add(-1 * time.Hour)
for ip, entry := range r.entries {
// Don't remove blocked entries until block expires
if entry.BlockedUntil != nil && now.Before(*entry.BlockedUntil) {
continue
}
// Remove if last attempt was more than an hour ago
if entry.LastAttemptAt.Before(expireThreshold) {
delete(r.entries, ip)
}
}
}
// Reset clears all entries (useful for testing)
func (r *RateLimiter) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = make(map[string]*RateLimitEntry)
}
// GetEntry returns the entry for an IP (useful for testing)
func (r *RateLimiter) GetEntry(ip string) *RateLimitEntry {
r.mu.RLock()
defer r.mu.RUnlock()
return r.entries[ip]
}
package controller
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// SetAdminLocation sets the admin's location filter preference via cookie
func SetAdminLocation(c *gin.Context) {
user, exists := c.Get("User")
if !exists {
c.Redirect(http.StatusFound, "/")
return
}
currentUser := user.(*model.User)
if !currentUser.IsAdmin() {
c.Redirect(http.StatusFound, "/")
return
}
locationIDStr := c.PostForm("location_id")
locationID, err := strconv.Atoi(locationIDStr)
if err != nil {
locationID = 0 // Default to all locations
}
// Validate that the location exists (if not 0)
if locationID > 0 {
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
locationID = 0 // Invalid location, reset to all
}
}
// Set cookie with location ID (expires in 30 days)
c.SetCookie("admin_location", strconv.Itoa(locationID), 60*60*24*30, "/", "", util.SecureCookies(), true)
// Redirect back to referer or home
referer := c.Request.Referer()
if referer == "" {
lang := c.Param("lang")
if lang == "" {
lang = "de"
}
referer = "/" + lang + "/"
}
c.Redirect(http.StatusFound, referer)
}
package controller
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// getEffectiveChildrenLocationFilter returns the location IDs to filter children by.
// For admin with selected location, returns that location.
// For LocationLead with delegation, returns their led locations.
// For admin without selection, returns nil (no filter).
func getEffectiveChildrenLocationFilter(c *gin.Context, user *model.User) ([]uint, error) {
// For admins, use the admin location filter from context
if user.IsAdmin() {
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
return []uint{uint(id)}, nil
}
}
// No filter selected - admin can see all
return nil, nil
}
// For LocationLeads with delegation, return their led locations
if user.IsHouseLeader() {
return user.GetLeadLocationIDs(model.DB)
}
return nil, nil
}
// canUserAccessChildLocation checks if user can access a child at the given location
func canUserAccessChildLocation(user *model.User, locationID uint) bool {
if user.IsAdmin() {
return true
}
if user.IsHouseLeader() {
leadLocations, err := user.GetLeadLocationIDs(model.DB)
if err != nil {
return false
}
for _, locID := range leadLocations {
if locID == locationID {
return true
}
}
}
return false
}
// AdminChildrenList shows all children for admin management
func AdminChildrenList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get effective location filter (admin selected or LocationLead's led locations)
locationFilter, err := getEffectiveChildrenLocationFilter(c, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Convert to single location pointer for existing service functions
// For multiple locations, we use the first one or nil
var adminLocationId *uint
if len(locationFilter) == 1 {
adminLocationId = &locationFilter[0]
} else if len(locationFilter) > 1 {
// For LocationLeads with multiple locations, use the first for now
// TODO: Extend service to support multiple locations
adminLocationId = &locationFilter[0]
}
// Get search query
searchQuery := c.Query("q")
// Get sort parameters
sortField := c.DefaultQuery("sort", "name")
sortOrder := c.DefaultQuery("order", "asc")
// Validate sort field
validSortFields := map[string]bool{"name": true, "birthday": true, "group": true, "status": true, "parents": true}
if !validSortFields[sortField] {
sortField = "name"
}
sortOption := service.ChildSortOption{
Field: sortField,
Desc: sortOrder == "desc",
}
var children []model.Child
if searchQuery != "" {
// Search with optional location filter
children, err = service.SearchChildren(model.DB, searchQuery, adminLocationId, sortOption)
} else {
// Get all children sorted
children, err = service.GetAllChildrenSorted(model.DB, adminLocationId, sortOption)
}
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads with multiple locations, filter to their locations
if len(locationFilter) > 1 {
var filteredChildren []model.Child
locationMap := make(map[uint]bool)
for _, locID := range locationFilter {
locationMap[locID] = true
}
for _, child := range children {
if child.Group != nil && locationMap[child.Group.LocationId] {
filteredChildren = append(filteredChildren, child)
}
}
children = filteredChildren
}
util.RenderHTMLOK(c, "admin-children-list.html", gin.H{
"lang": langStr,
"user": user,
"children": children,
"searchQuery": searchQuery,
"sortField": sortField,
"sortOrder": sortOrder,
"title": "Child Management",
})
}
// AdminChildDetail shows detailed information about a single child
func AdminChildDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child ID from URL
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load child with parents info
child, parentsInfo, err := service.GetChildWithParents(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they have access to this child's location
if !user.IsAdmin() && child.Group != nil {
if !canUserAccessChildLocation(user, child.Group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Format dates for display
var validFromStr, validUntilStr string
if child.ValidFrom != nil {
validFromStr = child.ValidFrom.Format("02.01.2006")
}
if child.ValidUntil != nil {
validUntilStr = child.ValidUntil.Format("02.01.2006")
}
util.RenderHTMLOK(c, "admin-children-detail.html", gin.H{
"lang": langStr,
"user": user,
"child": child,
"parentsInfo": parentsInfo,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": child.FirstName + " " + child.LastName,
})
}
// AdminShowCreateChild shows the form for creating a new child
func AdminShowCreateChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get effective location filter
locationFilter, err := getEffectiveChildrenLocationFilter(c, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
var adminLocationId *uint
if len(locationFilter) == 1 {
adminLocationId = &locationFilter[0]
}
// Get available groups (filtered by location for LocationLeads)
var groups []model.Group
if len(locationFilter) > 1 {
// For LocationLeads with multiple locations, get groups from all their locations
groups, err = service.GetGroupsByLocationIDs(model.DB, locationFilter)
} else {
groups, err = service.GetAllGroupsFiltered(model.DB, adminLocationId)
}
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-children-create.html", gin.H{
"lang": langStr,
"user": user,
"groups": groups,
"title": "Create Child",
})
}
// AdminCreateChild handles child creation
func AdminCreateChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
firstName := c.PostForm("first_name")
middleNames := c.PostForm("middle_names")
lastName := c.PostForm("last_name")
birthdayStr := c.PostForm("birthday")
groupIDStr := c.PostForm("group_id")
active := c.PostForm("active") == "on"
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Create child
child := model.Child{
FirstName: firstName,
MiddleNames: middleNames,
LastName: lastName,
Active: active,
}
// Parse birthday
if birthdayStr != "" {
if parsed, err := time.Parse("2006-01-02", birthdayStr); err == nil {
child.Birthday = parsed
}
}
// Parse group ID
if groupIDStr != "" {
if groupID, err := strconv.ParseUint(groupIDStr, 10, 32); err == nil {
gid := uint(groupID)
child.GroupId = &gid
}
}
// Parse validity dates
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
child.ValidFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
child.ValidUntil = &parsed
}
}
// Save child
if err := service.CreateChild(model.DB, &child); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/children/"+strconv.FormatUint(uint64(child.ID), 10))
}
// AdminShowEditChild shows the form for editing a child
func AdminShowEditChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child ID from URL
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load child
child, err := service.GetChildByID(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they have access to this child's location
if !user.IsAdmin() && child.Group != nil {
if !canUserAccessChildLocation(user, child.Group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get effective location filter
locationFilter, err := getEffectiveChildrenLocationFilter(c, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
var adminLocationId *uint
if len(locationFilter) == 1 {
adminLocationId = &locationFilter[0]
}
// Get available groups (filtered by location for LocationLeads)
var groups []model.Group
if len(locationFilter) > 1 {
groups, err = service.GetGroupsByLocationIDs(model.DB, locationFilter)
} else {
groups, err = service.GetAllGroupsFiltered(model.DB, adminLocationId)
}
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Format dates for form input
var birthdayStr, validFromStr, validUntilStr string
if !child.Birthday.IsZero() {
birthdayStr = child.Birthday.Format("2006-01-02")
}
if child.ValidFrom != nil {
validFromStr = child.ValidFrom.Format("2006-01-02")
}
if child.ValidUntil != nil {
validUntilStr = child.ValidUntil.Format("2006-01-02")
}
util.RenderHTMLOK(c, "admin-children-edit.html", gin.H{
"lang": langStr,
"user": user,
"child": child,
"groups": groups,
"birthdayStr": birthdayStr,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": "Edit Child",
})
}
// AdminUpdateChild handles child update
func AdminUpdateChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child ID from URL
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load child
child, err := service.GetChildByID(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
child.FirstName = c.PostForm("first_name")
child.MiddleNames = c.PostForm("middle_names")
child.LastName = c.PostForm("last_name")
child.Active = c.PostForm("active") == "on"
// Parse birthday
birthdayStr := c.PostForm("birthday")
if birthdayStr != "" {
if parsed, err := time.Parse("2006-01-02", birthdayStr); err == nil {
child.Birthday = parsed
}
}
// Parse group ID
groupIDStr := c.PostForm("group_id")
if groupIDStr != "" {
if groupID, err := strconv.ParseUint(groupIDStr, 10, 32); err == nil {
gid := uint(groupID)
child.GroupId = &gid
}
} else {
child.GroupId = nil
}
// Parse validity dates
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
child.ValidFrom = &parsed
}
} else {
child.ValidFrom = nil
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
child.ValidUntil = &parsed
}
} else {
child.ValidUntil = nil
}
// Save child
if err := service.UpdateChild(model.DB, child); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/children/"+childIDStr)
}
// ========== Child-Parent Relationship Management ==========
// AdminShowAddParent shows the form to add a parent to a child
func AdminShowAddParent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child ID from URL
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load child
child, err := service.GetChildByID(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they have access to this child's location
if !user.IsAdmin() && child.Group != nil {
if !canUserAccessChildLocation(user, child.Group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get admin location filter
var adminLocationId *uint
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
uid := uint(id)
adminLocationId = &uid
}
}
// Get available users (not yet assigned to this child)
availableUsers, err := service.GetAvailableUsersForChild(model.DB, uint(childID), adminLocationId)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get relationship roles
relationshipRoles := service.GetAllRelationshipRoles()
util.RenderHTMLOK(c, "admin-children-add-parent.html", gin.H{
"lang": langStr,
"user": user,
"child": child,
"availableUsers": availableUsers,
"relationshipRoles": relationshipRoles,
"title": "Add Parent",
})
}
// AdminAddParent handles adding a parent to a child
func AdminAddParent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child ID from URL
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
userIDStr := c.PostForm("user_id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
relationshipRole := model.RelationshipRole(c.PostForm("relationship_role"))
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Parse dates
var validFrom, validUntil *time.Time
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
validFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
validUntil = &parsed
}
}
// Add the relationship
err = service.AddChildUserRelationship(model.DB, uint(childID), uint(userID), relationshipRole, validFrom, validUntil)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/children/"+childIDStr)
}
// AdminShowEditParentRelation shows the form to edit a child-parent relationship
func AdminShowEditParentRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
childIDStr := c.Param("id")
userIDStr := c.Param("userid")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
parentUserID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load child
child, err := service.GetChildByID(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they have access to this child's location
if !user.IsAdmin() && child.Group != nil {
if !canUserAccessChildLocation(user, child.Group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Load relationship
childUser, err := service.GetChildUserRelationship(model.DB, uint(childID), uint(parentUserID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get relationship roles
relationshipRoles := service.GetAllRelationshipRoles()
// Format dates for form
var validFromStr, validUntilStr string
if childUser.ValidFrom != nil {
validFromStr = childUser.ValidFrom.Format("2006-01-02")
}
if childUser.ValidUntil != nil {
validUntilStr = childUser.ValidUntil.Format("2006-01-02")
}
util.RenderHTMLOK(c, "admin-children-edit-parent.html", gin.H{
"lang": langStr,
"user": user,
"child": child,
"childUser": childUser,
"relationshipRoles": relationshipRoles,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": "Edit Parent Relationship",
})
}
// AdminUpdateParentRelation handles updating a child-parent relationship
func AdminUpdateParentRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
childIDStr := c.Param("id")
userIDStr := c.Param("userid")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
parentUserID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
relationshipRole := model.RelationshipRole(c.PostForm("relationship_role"))
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Parse dates
var validFrom, validUntil *time.Time
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
validFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
validUntil = &parsed
}
}
// Update the relationship
err = service.UpdateChildUserRelationship(model.DB, uint(childID), uint(parentUserID), relationshipRole, validFrom, validUntil)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/children/"+childIDStr)
}
// AdminDeleteParentRelation handles removing a parent from a child
func AdminDeleteParentRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated children access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
childIDStr := c.Param("id")
userIDStr := c.Param("userid")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
parentUserID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Delete the relationship
err = service.DeleteChildUserRelationship(model.DB, uint(childID), uint(parentUserID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/children/"+childIDStr)
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// LocationLeadView is a view struct for displaying LocationLeads with their locations
type LocationLeadView struct {
User model.User
Locations []model.Location
}
// AdminDelegationSettings shows the delegation settings page
func AdminDelegationSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only admins can access delegation settings
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get current delegation settings
settings, err := model.GetDelegationSettings()
if err != nil {
settings = &model.DelegationSettings{}
}
// Get all LocationLeads for display
var locationLeads []model.User
model.DB.Preload("Roles").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name = ?", "LocationLead").
Find(&locationLeads)
// Build view with locations for each lead
var locationLeadViews []LocationLeadView
for _, lead := range locationLeads {
var locations []model.Location
model.DB.Where("lead_id = ? OR lead2nd_id = ?", lead.ID, lead.ID).Find(&locations)
locationLeadViews = append(locationLeadViews, LocationLeadView{
User: lead,
Locations: locations,
})
}
// Check for saved query parameter
saved := c.Query("saved") == "true"
util.RenderHTMLOK(c, "admin-delegation-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"locationLeads": locationLeadViews,
"title": "Delegation Settings",
"saved": saved,
})
}
// AdminUpdateDelegationSettings updates the delegation settings
func AdminUpdateDelegationSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only admins can update delegation settings
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
delegateUsers := c.PostForm("delegate_users") == "on"
delegateChildren := c.PostForm("delegate_children") == "on"
delegateGroups := c.PostForm("delegate_groups") == "on"
delegateEnrollments := c.PostForm("delegate_enrollments") == "on"
// Get or create settings
settings, err := model.GetDelegationSettings()
if err != nil {
settings = &model.DelegationSettings{}
}
settings.DelegateUsers = delegateUsers
settings.DelegateChildren = delegateChildren
settings.DelegateGroups = delegateGroups
settings.DelegateEnrollments = delegateEnrollments
if err := model.SaveDelegationSettings(settings); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/delegation?saved=true")
}
package controller
import (
"log"
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminEmailSettings shows the email settings form
func AdminEmailSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get current settings
settings, err := model.GetEmailSettings()
if err != nil {
log.Printf("Error loading email settings: %v", err)
}
// Create empty settings if none exist
if settings == nil {
settings = &model.EmailSettings{
SMTPPort: 587,
SMTPUseTLS: true,
}
}
util.RenderHTMLOK(c, "admin-email-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"title": "Email Settings",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminSaveEmailSettings handles saving the email settings
func AdminSaveEmailSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
smtpHost := c.PostForm("smtp_host")
smtpPortStr := c.PostForm("smtp_port")
smtpUser := c.PostForm("smtp_user")
smtpPassword := c.PostForm("smtp_password")
smtpFrom := c.PostForm("smtp_from")
smtpUseTLS := c.PostForm("smtp_tls") == "on"
enabled := c.PostForm("enabled") == "on"
smtpPort, err := strconv.Atoi(smtpPortStr)
if err != nil || smtpPort <= 0 {
smtpPort = 587
}
// Get existing settings to preserve password if not changed
existingSettings, _ := model.GetEmailSettings()
settings := &model.EmailSettings{
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPassword: smtpPassword,
SMTPFrom: smtpFrom,
SMTPUseTLS: smtpUseTLS,
Enabled: enabled,
}
// If password field is empty and we have existing settings, keep the old password
if smtpPassword == "" && existingSettings != nil {
settings.SMTPPassword = existingSettings.SMTPPassword
}
if err := model.SaveEmailSettings(settings); err != nil {
log.Printf("Error saving email settings: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/email-settings?error=save_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/email-settings?success=saved")
}
// AdminTestEmailSettings sends a test email
func AdminTestEmailSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get target email - use provided email or fall back to admin's email
targetEmail := c.PostForm("target_email")
if targetEmail == "" {
targetEmail = user.Email
}
// Send test email using the email service
err := service.SendTestEmail(targetEmail, langStr)
if err != nil {
log.Printf("Error sending test email to %s: %v", targetEmail, err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/email-settings?error=test_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/email-settings?success=test_sent")
}
package controller
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// getEffectiveEnrollmentLocationFilter returns the location IDs to filter enrollments by.
// For admin with selected location, returns that location.
// For LocationLead with delegation, returns their led locations.
// For admin without selection, returns nil (no filter).
func getEffectiveEnrollmentLocationFilter(c *gin.Context, user *model.User) ([]uint, error) {
// For admins, use the admin location filter from context
if user.IsAdmin() {
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
return []uint{uint(id)}, nil
}
}
// No filter selected - admin can see all
return nil, nil
}
// For LocationLeads with delegation, return their led locations
if user.IsHouseLeader() {
return user.GetLeadLocationIDs(model.DB)
}
return nil, nil
}
// AdminEnrollmentsList shows all enrollments with optional filtering
func AdminEnrollmentsList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get effective location filter
locationFilter, err := getEffectiveEnrollmentLocationFilter(c, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
var adminLocationId *uint
if len(locationFilter) == 1 {
adminLocationId = &locationFilter[0]
}
// Get filter parameters
var childID, groupID *uint
var status *int
if cidStr := c.Query("child_id"); cidStr != "" {
if cid, err := strconv.ParseUint(cidStr, 10, 32); err == nil {
uid := uint(cid)
childID = &uid
}
}
if gidStr := c.Query("group_id"); gidStr != "" {
if gid, err := strconv.ParseUint(gidStr, 10, 32); err == nil {
uid := uint(gid)
groupID = &uid
}
}
if statusStr := c.Query("status"); statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
status = &s
}
}
enrollments, err := service.GetAllEnrollments(model.DB, childID, groupID, adminLocationId, status)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Load children and groups for filter dropdowns
children, _ := service.GetAllChildrenSorted(model.DB, adminLocationId, service.ChildSortOption{Field: "name", Desc: false})
groups, _ := service.GetAllGroupsFiltered(model.DB, adminLocationId)
util.RenderHTMLOK(c, "admin-enrollments-list.html", gin.H{
"lang": langStr,
"user": user,
"enrollments": enrollments,
"children": children,
"groups": groups,
"childID": childID,
"groupID": groupID,
"status": status,
"title": "Enrollment Management",
})
}
// AdminShowCreateEnrollment shows the form to create a new enrollment
func AdminShowCreateEnrollment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get effective location filter
locationFilter, _ := getEffectiveEnrollmentLocationFilter(c, user)
var adminLocationId *uint
if len(locationFilter) == 1 {
adminLocationId = &locationFilter[0]
}
// Load children and groups for dropdowns
var children []model.Child
var groups []model.Group
if len(locationFilter) > 1 {
children, _ = service.GetAllChildrenSorted(model.DB, nil, service.ChildSortOption{Field: "name", Desc: false})
// Filter children to LocationLead's locations
locationMap := make(map[uint]bool)
for _, locID := range locationFilter {
locationMap[locID] = true
}
var filteredChildren []model.Child
for _, child := range children {
if child.Group != nil && locationMap[child.Group.LocationId] {
filteredChildren = append(filteredChildren, child)
}
}
children = filteredChildren
groups, _ = service.GetGroupsByLocationIDs(model.DB, locationFilter)
} else {
children, _ = service.GetAllChildrenSorted(model.DB, adminLocationId, service.ChildSortOption{Field: "name", Desc: false})
groups, _ = service.GetAllGroupsFiltered(model.DB, adminLocationId)
}
// Pre-select child if provided in query
var selectedChildID uint
if cidStr := c.Query("child_id"); cidStr != "" {
if cid, err := strconv.ParseUint(cidStr, 10, 32); err == nil {
selectedChildID = uint(cid)
}
}
util.RenderHTMLOK(c, "admin-enrollments-create.html", gin.H{
"lang": langStr,
"user": user,
"children": children,
"groups": groups,
"selectedChildID": selectedChildID,
"title": "Create Enrollment",
})
}
// AdminCreateEnrollment creates a new enrollment from form data
func AdminCreateEnrollment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
childIDStr := c.PostForm("child_id")
groupIDStr := c.PostForm("group_id")
statusStr := c.PostForm("status")
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
careDaysBinaryStr := c.PostForm("care_days_binary")
careDaysCountStr := c.PostForm("care_days_count")
comments := c.PostForm("comments")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/enrollments/new?error=invalid_child")
return
}
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/enrollments/new?error=invalid_group")
return
}
status := service.EnrollmentStatusActive // Default
if statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
status = s
}
}
enrollment := model.Enrollment{
ChildID: uint(childID),
GroupID: uint(groupID),
Status: status,
Comments: comments,
}
// Parse optional dates
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
enrollment.ValidFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
enrollment.ValidUntil = &parsed
}
}
// Parse care days
if careDaysBinaryStr != "" {
if cdb, err := strconv.Atoi(careDaysBinaryStr); err == nil {
enrollment.CareDaysBinary = cdb
}
}
if careDaysCountStr != "" {
if cdc, err := strconv.Atoi(careDaysCountStr); err == nil {
enrollment.CareDaysCount = cdc
}
}
if err := service.CreateEnrollment(model.DB, &enrollment); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/enrollments")
}
// AdminShowEditEnrollment shows the form to edit an enrollment
func AdminShowEditEnrollment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
enrollmentIDStr := c.Param("id")
enrollmentID, err := strconv.ParseUint(enrollmentIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
enrollment, err := service.GetEnrollmentByID(model.DB, uint(enrollmentID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get admin location filter
var adminLocationId *uint
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
uid := uint(id)
adminLocationId = &uid
}
}
// Load children and groups for dropdowns
children, _ := service.GetAllChildrenSorted(model.DB, adminLocationId, service.ChildSortOption{Field: "name", Desc: false})
groups, _ := service.GetAllGroupsFiltered(model.DB, adminLocationId)
// Format dates for form inputs
var validFromStr, validUntilStr string
if enrollment.ValidFrom != nil {
validFromStr = enrollment.ValidFrom.Format("2006-01-02")
}
if enrollment.ValidUntil != nil {
validUntilStr = enrollment.ValidUntil.Format("2006-01-02")
}
util.RenderHTMLOK(c, "admin-enrollments-edit.html", gin.H{
"lang": langStr,
"user": user,
"enrollment": enrollment,
"children": children,
"groups": groups,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": "Edit Enrollment",
})
}
// AdminUpdateEnrollment updates an existing enrollment
func AdminUpdateEnrollment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
enrollmentIDStr := c.Param("id")
enrollmentID, err := strconv.ParseUint(enrollmentIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
enrollment, err := service.GetEnrollmentByID(model.DB, uint(enrollmentID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
childIDStr := c.PostForm("child_id")
groupIDStr := c.PostForm("group_id")
statusStr := c.PostForm("status")
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
careDaysBinaryStr := c.PostForm("care_days_binary")
careDaysCountStr := c.PostForm("care_days_count")
comments := c.PostForm("comments")
if childID, err := strconv.ParseUint(childIDStr, 10, 32); err == nil {
enrollment.ChildID = uint(childID)
}
if groupID, err := strconv.ParseUint(groupIDStr, 10, 32); err == nil {
enrollment.GroupID = uint(groupID)
}
if statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
enrollment.Status = s
}
}
enrollment.Comments = comments
// Parse optional dates
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
enrollment.ValidFrom = &parsed
}
} else {
enrollment.ValidFrom = nil
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
enrollment.ValidUntil = &parsed
}
} else {
enrollment.ValidUntil = nil
}
// Parse care days
if careDaysBinaryStr != "" {
if cdb, err := strconv.Atoi(careDaysBinaryStr); err == nil {
enrollment.CareDaysBinary = cdb
}
} else {
enrollment.CareDaysBinary = 0
}
if careDaysCountStr != "" {
if cdc, err := strconv.Atoi(careDaysCountStr); err == nil {
enrollment.CareDaysCount = cdc
}
} else {
enrollment.CareDaysCount = 0
}
if err := service.UpdateEnrollment(model.DB, enrollment); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/enrollments")
}
// AdminDeleteEnrollment soft-deletes an enrollment
func AdminDeleteEnrollment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated enrollments access
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
enrollmentIDStr := c.Param("id")
enrollmentID, err := strconv.ParseUint(enrollmentIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
if err := service.DeleteEnrollment(model.DB, uint(enrollmentID)); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/enrollments")
}
package controller
import (
"log"
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminFAQSettings shows the FAQ settings form
func AdminFAQSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get current settings
settings, err := model.GetFAQSettings()
if err != nil {
log.Printf("Error loading FAQ settings: %v", err)
}
// Create empty settings if none exist
if settings == nil {
settings = &model.FAQSettings{}
}
util.RenderHTMLOK(c, "admin-faq-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"title": "FAQ Settings",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminSaveFAQSettings handles saving the FAQ settings
func AdminSaveFAQSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
settings := &model.FAQSettings{
FAQParentDE: c.PostForm("faq_parent_de"),
FAQParentEN: c.PostForm("faq_parent_en"),
FAQEmployeeDE: c.PostForm("faq_employee_de"),
FAQEmployeeEN: c.PostForm("faq_employee_en"),
}
if err := model.SaveFAQSettings(settings); err != nil {
log.Printf("Error saving FAQ settings: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/faq-settings?error=save_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/faq-settings?success=saved")
}
// AdminPreviewFAQMarkdown returns rendered markdown with raw HTML passthrough for FAQ preview
func AdminPreviewFAQMarkdown(c *gin.Context) {
userInterface, ok := c.Get("User")
if !ok {
c.String(http.StatusUnauthorized, "Unauthorized")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
c.String(http.StatusForbidden, "Forbidden")
return
}
markdown := c.PostForm("markdown")
html := util.RenderMarkdownUnsafe(markdown)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
package controller
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminGroupsList shows all groups for admin management
func AdminGroupsList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get LocationLead's locations for scoping (nil for admin = no restriction)
var allowedLocationIDs []uint
if !isAdmin && hasDelegatedAccess {
var err error
allowedLocationIDs, err = user.GetLeadLocationIDs(model.DB)
if err != nil || len(allowedLocationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get search query
searchQuery := c.Query("q")
// Get sort parameters
sortField := c.DefaultQuery("sort", "name")
sortOrder := c.DefaultQuery("order", "asc")
// Get location filter
locationFilter := c.Query("location")
// Validate sort field
validSortFields := map[string]bool{"name": true, "location": true}
if !validSortFields[sortField] {
sortField = "name"
}
// Build query
query := model.DB.Model(&model.Group{}).Preload("Location").Preload("Lead")
// For LocationLeads, restrict to their locations
if len(allowedLocationIDs) > 0 {
query = query.Where("location_id IN ?", allowedLocationIDs)
}
// Apply location filter
if locationFilter != "" {
if locID, err := strconv.ParseUint(locationFilter, 10, 32); err == nil {
query = query.Where("location_id = ?", locID)
}
}
// Apply search filter
if searchQuery != "" {
query = query.Where("name LIKE ?", "%"+searchQuery+"%")
}
// Apply sorting
switch sortField {
case "name":
if sortOrder == "desc" {
query = query.Order("name DESC")
} else {
query = query.Order("name ASC")
}
case "location":
// Sort by location name requires a join
query = query.Joins("LEFT JOIN locations ON locations.id = groups.location_id")
if sortOrder == "desc" {
query = query.Order("locations.name DESC")
} else {
query = query.Order("locations.name ASC")
}
default:
query = query.Order("name ASC")
}
var groups []model.Group
if err := query.Find(&groups).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get child counts for each group
type GroupWithChildCount struct {
model.Group
ChildCount int64
}
var groupsWithCounts []GroupWithChildCount
for _, grp := range groups {
var childCount int64
model.DB.Model(&model.Child{}).Where("group_id = ?", grp.ID).Count(&childCount)
groupsWithCounts = append(groupsWithCounts, GroupWithChildCount{
Group: grp,
ChildCount: childCount,
})
}
// Get locations for filter dropdown (scoped for LocationLeads)
var locations []model.Location
if len(allowedLocationIDs) > 0 {
model.DB.Where("id IN ?", allowedLocationIDs).Order("name ASC").Find(&locations)
} else {
model.DB.Order("name ASC").Find(&locations)
}
util.RenderHTMLOK(c, "admin-groups-list.html", gin.H{
"lang": langStr,
"user": user,
"groups": groupsWithCounts,
"locations": locations,
"searchQuery": searchQuery,
"sortField": sortField,
"sortOrder": sortOrder,
"locationFilter": locationFilter,
"title": "Group Management",
})
}
// AdminShowCreateGroup shows the form for creating a new group
func AdminShowCreateGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get LocationLead's locations for scoping
var allowedLocationIDs []uint
if !isAdmin && hasDelegatedAccess {
var err error
allowedLocationIDs, err = user.GetLeadLocationIDs(model.DB)
if err != nil || len(allowedLocationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get locations for selection (scoped for LocationLeads)
var locations []model.Location
if len(allowedLocationIDs) > 0 {
model.DB.Where("id IN ?", allowedLocationIDs).Order("name ASC").Find(&locations)
} else {
model.DB.Order("name ASC").Find(&locations)
}
// Get available users for group lead selection (employees and admins)
var employees []model.User
model.DB.Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Distinct().
Order("last_name ASC, first_name ASC").
Find(&employees)
// Pre-select location if passed as query param
preselectedLocation := c.Query("location")
util.RenderHTMLOK(c, "admin-groups-create.html", gin.H{
"lang": langStr,
"user": user,
"locations": locations,
"employees": employees,
"preselectedLocation": preselectedLocation,
"title": "Create Group",
})
}
// AdminCreateGroup handles group creation
func AdminCreateGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
name := c.PostForm("name")
locationIDStr := c.PostForm("location_id")
leadIDStr := c.PostForm("lead_id")
// Validate required fields
if name == "" || locationIDStr == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse location ID
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they can access this location
if !isAdmin && hasDelegatedAccess {
locID := uint(locationID)
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, &locID) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Verify location exists
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Create group
group := model.Group{
Name: name,
LocationId: uint(locationID),
}
// Parse lead ID
if leadIDStr != "" {
if leadID, err := strconv.ParseUint(leadIDStr, 10, 32); err == nil {
lid := uint(leadID)
group.LeadId = &lid
}
}
// Save group
if err := model.DB.Create(&group).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/groups")
}
// AdminShowEditGroup shows the form for editing a group
func AdminShowEditGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get group ID from URL
groupIDStr := c.Param("id")
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load group
var group model.Group
if err := model.DB.Preload("Location").Preload("Lead").First(&group, groupID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they can access this group's location
if !isAdmin && hasDelegatedAccess {
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, &group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get LocationLead's locations for scoping
var allowedLocationIDs []uint
if !isAdmin && hasDelegatedAccess {
allowedLocationIDs, _ = user.GetLeadLocationIDs(model.DB)
}
// Get locations for selection (scoped for LocationLeads)
var locations []model.Location
if len(allowedLocationIDs) > 0 {
model.DB.Where("id IN ?", allowedLocationIDs).Order("name ASC").Find(&locations)
} else {
model.DB.Order("name ASC").Find(&locations)
}
// Get available users for group lead selection (employees and admins)
var employees []model.User
model.DB.Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Distinct().
Order("last_name ASC, first_name ASC").
Find(&employees)
// Get children in this group
var children []model.Child
model.DB.Where("group_id = ?", group.ID).
Order("last_name ASC, first_name ASC").
Find(&children)
// Get teachers in this group
var teachers []model.User
model.DB.Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Where("group_teachers.group_id = ?", group.ID).
Order("last_name ASC, first_name ASC").
Find(&teachers)
util.RenderHTMLOK(c, "admin-groups-edit.html", gin.H{
"lang": langStr,
"user": user,
"group": group,
"locations": locations,
"employees": employees,
"children": children,
"teachers": teachers,
"title": "Edit Group",
})
}
// AdminUpdateGroup handles group update
func AdminUpdateGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get group ID from URL
groupIDStr := c.Param("id")
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load group
var group model.Group
if err := model.DB.First(&group, groupID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they can access this group's current location
if !isAdmin && hasDelegatedAccess {
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, &group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Parse form data
name := c.PostForm("name")
locationIDStr := c.PostForm("location_id")
leadIDStr := c.PostForm("lead_id")
// Validate required fields
if name == "" || locationIDStr == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse location ID
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they can access the new location as well
if !isAdmin && hasDelegatedAccess {
newLocID := uint(locationID)
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, &newLocID) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Verify location exists
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Update group fields
group.Name = name
group.LocationId = uint(locationID)
// Parse lead ID
if leadIDStr != "" {
if leadID, err := strconv.ParseUint(leadIDStr, 10, 32); err == nil {
lid := uint(leadID)
group.LeadId = &lid
}
} else {
group.LeadId = nil
}
// Save group
if err := model.DB.Save(&group).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/groups")
}
// AdminGroupsDelete handles group deletion
func AdminGroupsDelete(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated groups access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get group ID from URL
groupIDStr := c.Param("id")
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load group to check location
var group model.Group
if err := model.DB.First(&group, groupID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify they can access this group's location
if !isAdmin && hasDelegatedAccess {
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, &group.LocationId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Check if group has children
var childCount int64
model.DB.Model(&model.Child{}).Where("group_id = ?", groupID).Count(&childCount)
if childCount > 0 {
// Cannot delete group with children - redirect with error
c.Redirect(http.StatusFound, "/"+langStr+"/admin/groups?error=has_children")
return
}
// Delete group (soft delete via gorm.Model)
if err := model.DB.Delete(&model.Group{}, groupID).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/groups")
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminHolidaySettings shows the holiday import settings form
func AdminHolidaySettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
settings, err := model.GetHolidaySettings()
if err != nil {
logger.Error("Error loading holiday settings", "error", err)
}
if settings == nil {
settings = &model.HolidaySettings{
APIURL: "https://date.nager.at",
CountryCode: "DE",
Enabled: true,
}
}
util.RenderHTMLOK(c, "admin-holiday-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"title": "Holiday Settings",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminHolidaySettingsSave handles saving the holiday import settings
func AdminHolidaySettingsSave(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
apiURL := c.PostForm("api_url")
countryCode := c.PostForm("country_code")
enabled := c.PostForm("enabled") == "on"
if apiURL == "" {
apiURL = "https://date.nager.at"
}
if countryCode == "" {
countryCode = "DE"
}
settings := &model.HolidaySettings{
APIURL: apiURL,
CountryCode: countryCode,
Enabled: enabled,
}
if err := model.SaveHolidaySettings(settings); err != nil {
logger.Error("Error saving holiday settings", "error", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/holiday-settings?error=save_failed")
return
}
logger.Info("Holiday settings updated", "userId", user.ID, "enabled", enabled)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/holiday-settings?success=saved")
}
package controller
// Import/Export of the whole database (#459). Admin-only.
// The import side is gated by APP_ALLOW_PROD_IMPORT so production
// never offers a database-replace button.
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service/dbexport"
"wippidu_app_backend/internal/service/dbimport"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminImportExport renders the import/export page. Export is offered on
// every instance; the import section is shown only when imports are enabled.
func AdminImportExport(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, exists := c.Get("User")
if !exists {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Render the most recent audit rows so the operator can see what's
// happened recently on this instance.
var recentAudits []model.ImportExportAudit
model.DB.Order("created_at DESC").Limit(10).Find(&recentAudits)
// If the just-redirected success message is an import, parse the
// latest audit's DetailsJSON so the result page shows row counts
// and which anonymizers ran.
var latestImportDetails *dbimport.Stats
if c.Query("success") == "import.completed" && len(recentAudits) > 0 {
var s dbimport.Stats
if err := json.Unmarshal([]byte(recentAudits[0].DetailsJSON), &s); err == nil {
latestImportDetails = &s
}
}
util.RenderHTMLOK(c, "admin-import-export.html", gin.H{
"lang": langStr,
"user": user,
"title": "Import/Export",
"importEnabled": util.IsImportEnabled(),
"expectedHost": c.Request.Host,
"recentAudits": recentAudits,
"latestImportDetails": latestImportDetails,
"success": c.Query("success"),
"errorKey": c.Query("error"),
// Pass `false` so the import/export page itself doesn't show
// the home-banner — recentAudits already covers the same info.
"lastImport": nil,
})
}
// AdminImportExportExport streams a gzipped JSON dump of the full database
// to the admin's browser as a download.
func AdminImportExportExport(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, exists := c.Get("User")
if !exists {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
includeAttachments := c.PostForm("include_attachments") == "on" || c.PostForm("include_attachments") == "true"
filename := fmt.Sprintf("wippidu-export-%s.json.gz", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Header("Cache-Control", "no-store")
err := dbexport.Export(model.DB, c.Writer, dbexport.Options{
IncludeAttachments: includeAttachments,
ExportedFrom: c.Request.Host,
})
if err != nil {
// Stream may already be partially written; we can't switch to an
// HTML error page. Log and let the client see a truncated file —
// the gzip footer will be missing so it'll fail to decompress.
logger.Error("db export failed", "error", err, "user", user.Email)
}
}
// piiRiskAckMagic is the literal the operator must type to opt out of
// any anonymizer. Documented in the import form as the risk
// acknowledgement.
const piiRiskAckMagic = "I_UNDERSTAND_THE_PII_RISK"
// readAnonymizeOptions parses the import form's six anonymizer
// checkboxes plus the PII risk-acknowledgement field. Returns the
// effective options to use and an i18n error key when the form fails
// the gate (operator turned a scrubber off without acknowledging).
//
// Convention: the checkbox value is "on" (HTML default) when ticked,
// missing when unticked. We treat presence-of-value as "scrub". This
// way a missing form key (e.g. someone hits the endpoint without a
// form) falls through to all-on, the safe default.
func readAnonymizeOptions(c *gin.Context) (dbimport.AnonymizeOptions, string) {
checked := func(name string) bool {
v := c.PostForm("anon_" + name)
return v == "on" || v == "true" || v == "1"
}
opts := dbimport.AnonymizeOptions{
Emails: checked("emails"),
Names: checked("names"),
Contacts: checked("contacts"),
PasswordHashes: checked("password_hashes"),
Bodies: checked("bodies"),
Attachments: checked("attachments"),
}
if opts.AnyDisabled() {
if strings.TrimSpace(c.PostForm("pii_risk_ack")) != piiRiskAckMagic {
return opts, "import.pii_risk_ack_required"
}
}
return opts, ""
}
// AdminImportExportImport accepts a gzipped JSON dump and performs a
// full-replace import. Gated by APP_ALLOW_PROD_IMPORT.
//
// The operator must type this instance's hostname into a confirmation
// field; we compare against c.Request.Host server-side so a crafted form
// can't bypass it.
//
// Audit row is written OUTSIDE the import transaction so failures still
// produce an entry the operator can inspect afterwards.
func AdminImportExportImport(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, exists := c.Get("User")
if !exists {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
if !util.IsImportEnabled() {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error=import.disabled")
return
}
confirm := strings.TrimSpace(c.PostForm("confirm_hostname"))
if confirm != c.Request.Host {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error=import.hostname_mismatch")
return
}
fh, err := c.FormFile("dump")
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error=import.no_file")
return
}
f, err := fh.Open()
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error=import.cannot_open")
return
}
defer f.Close()
anon, gateErr := readAnonymizeOptions(c)
if gateErr != "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error="+gateErr)
return
}
stats, importErr := dbimport.Import(model.DB, f, anon)
// Build the audit row regardless of outcome. Email travels inline
// because the user record may itself be wiped by the import.
audit := model.ImportExportAudit{
Action: "import",
UserID: user.ID,
UserEmail: user.Email,
Bytes: fh.Size,
Result: "success",
}
if stats != nil {
audit.SourceHost = stats.ExportedFrom
audit.SourceExportedAt = stats.ExportedAt
if details, err := json.Marshal(stats); err == nil {
audit.DetailsJSON = string(details)
}
}
if importErr != nil {
audit.Result = "failure"
msg := importErr.Error()
if len(msg) > 2000 {
msg = msg[:2000]
}
audit.ErrorMessage = msg
logger.Error("db import failed", "error", importErr, "user", user.Email)
}
if err := model.DB.Create(&audit).Error; err != nil {
logger.Error("failed to write import-export audit row", "error", err)
}
if importErr != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?error=import.failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/import-export?success=import.completed")
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminIntranetSettings shows the intranet settings form
func AdminIntranetSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get current settings
settings, err := model.GetIntranetSettings()
if err != nil {
logger.Error("Error loading intranet settings", "error", err)
}
// Create default settings if none exist
if settings == nil {
settings = &model.IntranetSettings{
APIURL: "https://www.wippidu.org/MA_Gruppe_Direct.php",
Enabled: false,
}
}
util.RenderHTMLOK(c, "admin-intranet-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"title": "Intranet Settings",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminIntranetSettingsSave handles saving the intranet settings
func AdminIntranetSettingsSave(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
apiURL := c.PostForm("api_url")
apiToken := c.PostForm("api_token")
enabled := c.PostForm("enabled") == "on"
// Get existing settings to preserve token if not changed
existingSettings, _ := model.GetIntranetSettings()
settings := &model.IntranetSettings{
APIURL: apiURL,
APIToken: apiToken,
Enabled: enabled,
}
// If token field is empty and we have existing settings, keep the old token
if apiToken == "" && existingSettings != nil {
settings.APIToken = existingSettings.APIToken
}
if err := model.SaveIntranetSettings(settings); err != nil {
logger.Error("Error saving intranet settings", "error", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?error=save_failed")
return
}
logger.Info("Intranet settings updated", "userId", user.ID, "enabled", enabled)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?success=saved")
}
// AdminIntranetSettingsTest tests the intranet API connection
func AdminIntranetSettingsTest(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get test employee ID from form
testEmployeeID := c.PostForm("test_employee_id")
if testEmployeeID == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?error=no_test_id")
return
}
// Get current settings
settings, err := model.GetIntranetSettings()
if err != nil || settings == nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?error=no_settings")
return
}
// Test the connection
err = service.TestIntranetConnection(settings, testEmployeeID)
if err != nil {
logger.Warn("Intranet connection test failed",
"userId", user.ID,
"testEmployeeId", testEmployeeID,
"error", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?error=test_failed")
return
}
logger.Info("Intranet connection test successful",
"userId", user.ID,
"testEmployeeId", testEmployeeID)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/intranet-settings?success=test_ok")
}
package controller
import (
"log"
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminLegalSettings shows the legal settings form
func AdminLegalSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get current settings
settings, err := model.GetLegalSettings()
if err != nil {
log.Printf("Error loading legal settings: %v", err)
}
// Create empty settings if none exist
if settings == nil {
settings = &model.LegalSettings{}
}
util.RenderHTMLOK(c, "admin-legal-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"title": "Legal Settings",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminSaveLegalSettings handles saving the legal settings
func AdminSaveLegalSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
settings := &model.LegalSettings{
ImpressumDE: c.PostForm("impressum_de"),
ImpressumEN: c.PostForm("impressum_en"),
DatenschutzDE: c.PostForm("datenschutz_de"),
DatenschutzEN: c.PostForm("datenschutz_en"),
}
if err := model.SaveLegalSettings(settings); err != nil {
log.Printf("Error saving legal settings: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/legal-settings?error=save_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/legal-settings?success=saved")
}
// AdminPreviewMarkdown returns rendered markdown (for live preview via AJAX)
func AdminPreviewMarkdown(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.String(http.StatusUnauthorized, "Unauthorized")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
c.String(http.StatusForbidden, "Forbidden")
return
}
markdown := c.PostForm("markdown")
html := util.RenderMarkdown(markdown)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
package controller
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminLocationsList shows all locations for admin management
func AdminLocationsList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get search query
searchQuery := c.Query("q")
// Get sort parameters
sortField := c.DefaultQuery("sort", "name")
sortOrder := c.DefaultQuery("order", "asc")
// Validate sort field
validSortFields := map[string]bool{"name": true, "address": true, "groups": true}
if !validSortFields[sortField] {
sortField = "name"
}
// Build query
query := model.DB.Model(&model.Location{}).Preload("Lead").Preload("Lead2nd")
// Apply search filter
if searchQuery != "" {
query = query.Where("name LIKE ? OR address LIKE ?", "%"+searchQuery+"%", "%"+searchQuery+"%")
}
// Apply sorting
switch sortField {
case "name":
if sortOrder == "desc" {
query = query.Order("name DESC")
} else {
query = query.Order("name ASC")
}
case "address":
if sortOrder == "desc" {
query = query.Order("address DESC")
} else {
query = query.Order("address ASC")
}
default:
query = query.Order("name ASC")
}
var locations []model.Location
if err := query.Find(&locations).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get group counts for each location
type LocationWithGroupCount struct {
model.Location
GroupCount int64
}
var locationsWithCounts []LocationWithGroupCount
for _, loc := range locations {
var groupCount int64
model.DB.Model(&model.Group{}).Where("location_id = ?", loc.ID).Count(&groupCount)
locationsWithCounts = append(locationsWithCounts, LocationWithGroupCount{
Location: loc,
GroupCount: groupCount,
})
}
util.RenderHTMLOK(c, "admin-locations-list.html", gin.H{
"lang": langStr,
"user": user,
"locations": locationsWithCounts,
"searchQuery": searchQuery,
"sortField": sortField,
"sortOrder": sortOrder,
"title": "Location Management",
})
}
// AdminShowCreateLocation shows the form for creating a new location
func AdminShowCreateLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get available users for location lead selection (employees and admins)
var employees []model.User
model.DB.Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Distinct().
Order("last_name ASC, first_name ASC").
Find(&employees)
util.RenderHTMLOK(c, "admin-locations-create.html", gin.H{
"lang": langStr,
"user": user,
"employees": employees,
"title": "Create Location",
})
}
// AdminCreateLocation handles location creation
func AdminCreateLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
name := c.PostForm("name")
address := c.PostForm("address")
leadIDStr := c.PostForm("lead_id")
lead2ndIDStr := c.PostForm("lead2nd_id")
employeeLocationAccess := c.PostForm("employee_location_access")
if employeeLocationAccess == "" {
employeeLocationAccess = string(model.EmployeeAccessLocationLeads)
}
// Validate required fields
if name == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Create location
location := model.Location{
Name: name,
Address: address,
EmployeeLocationAccess: model.EmployeeLocationAccess(employeeLocationAccess),
}
// Parse lead IDs
if leadIDStr != "" {
if leadID, err := strconv.ParseUint(leadIDStr, 10, 32); err == nil {
lid := uint(leadID)
location.LeadId = &lid
}
}
if lead2ndIDStr != "" {
if lead2ndID, err := strconv.ParseUint(lead2ndIDStr, 10, 32); err == nil {
lid := uint(lead2ndID)
location.Lead2ndId = &lid
}
}
// Save location
if err := model.DB.Create(&location).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/locations")
}
// AdminShowEditLocation shows the form for editing a location
func AdminShowEditLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID from URL
locationIDStr := c.Param("id")
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load location
var location model.Location
if err := model.DB.Preload("Lead").Preload("Lead2nd").First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get available users for location lead selection (employees and admins)
var employees []model.User
model.DB.Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Distinct().
Order("last_name ASC, first_name ASC").
Find(&employees)
// Get groups at this location
var groups []model.Group
model.DB.Where("location_id = ?", location.ID).Order("name ASC").Find(&groups)
util.RenderHTMLOK(c, "admin-locations-edit.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"employees": employees,
"groups": groups,
"title": "Edit Location",
})
}
// AdminUpdateLocation handles location update
func AdminUpdateLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID from URL
locationIDStr := c.Param("id")
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load location
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
name := c.PostForm("name")
address := c.PostForm("address")
leadIDStr := c.PostForm("lead_id")
lead2ndIDStr := c.PostForm("lead2nd_id")
employeeLocationAccess := c.PostForm("employee_location_access")
if employeeLocationAccess == "" {
employeeLocationAccess = string(model.EmployeeAccessLocationLeads)
}
delegateUsers := c.PostForm("delegate_users") == "on"
delegateChildren := c.PostForm("delegate_children") == "on"
delegateGroups := c.PostForm("delegate_groups") == "on"
delegateEnrollments := c.PostForm("delegate_enrollments") == "on"
// Validate required fields
if name == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Update location fields
location.Name = name
location.Address = address
location.EmployeeLocationAccess = model.EmployeeLocationAccess(employeeLocationAccess)
location.DelegateUsers = delegateUsers
location.DelegateChildren = delegateChildren
location.DelegateGroups = delegateGroups
location.DelegateEnrollments = delegateEnrollments
// Parse lead IDs
if leadIDStr != "" {
if leadID, err := strconv.ParseUint(leadIDStr, 10, 32); err == nil {
lid := uint(leadID)
location.LeadId = &lid
}
} else {
location.LeadId = nil
}
if lead2ndIDStr != "" {
if lead2ndID, err := strconv.ParseUint(lead2ndIDStr, 10, 32); err == nil {
lid := uint(lead2ndID)
location.Lead2ndId = &lid
}
} else {
location.Lead2ndId = nil
}
// Save location
if err := model.DB.Save(&location).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/locations")
}
// AdminDeleteLocation handles location deletion
func AdminDeleteLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID from URL
locationIDStr := c.Param("id")
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Check if location has groups
var groupCount int64
model.DB.Model(&model.Group{}).Where("location_id = ?", locationID).Count(&groupCount)
if groupCount > 0 {
// Cannot delete location with groups - redirect with error
c.Redirect(http.StatusFound, "/"+langStr+"/admin/locations?error=has_groups")
return
}
// Delete location (soft delete via gorm.Model)
if err := model.DB.Delete(&model.Location{}, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/locations")
}
package controller
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/storage"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminNewsList shows global news for admin management
func AdminNewsList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Query only global news (location_id IS NULL AND group_id IS NULL)
var newsList []model.News
model.DB.Order("published_at DESC").
Preload("CreatedBy").
Where("location_id IS NULL AND group_id IS NULL").
Find(&newsList)
util.RenderHTMLOK(c, "admin-news-list.html", gin.H{
"lang": langStr,
"user": user,
"news": newsList,
"title": "Global News",
})
}
// AdminShowCreateNews shows the form for creating global news as admin
func AdminShowCreateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-news-create.html", gin.H{
"lang": langStr,
"user": user,
"title": "Create Global News",
"maxAttachments": storage.MaxAttachmentsCount,
})
}
// AdminCreateNews handles global news creation by admin
func AdminCreateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
title := c.PostForm("title")
text := c.PostForm("text")
validUntilStr := c.PostForm("valid_until")
// Validate required fields
if title == "" || text == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/news/new?error=missing_fields")
return
}
// Parse valid until date
var validUntil *time.Time
if validUntilStr != "" {
parsedTime, err := time.Parse("2006-01-02", validUntilStr)
if err == nil {
validUntil = &parsedTime
}
}
// Create global news (LocationId and GroupId are both nil)
news := model.News{
Title: title,
Text: text,
CreatedById: user.ID,
PublishedAt: time.Now(),
ValidUntil: validUntil,
LocationId: nil,
GroupId: nil,
}
if err := model.DB.Create(&news).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Associate AJAX-uploaded orphan attachments with the news
attachmentIDStrs := c.PostFormArray("attachment_ids")
if len(attachmentIDStrs) > 0 {
var attachmentIDs []uint
for _, idStr := range attachmentIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
attachmentIDs = append(attachmentIDs, uint(parsed))
}
}
if len(attachmentIDs) > 0 {
fileStorage := storage.NewDBStorage(model.DB)
fileStorage.AssociateOrphansToNews(attachmentIDs, news.ID)
}
}
// Send email notifications to opted-in recipients
service.NotifyNewsPublished(model.DB, &news)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/news")
}
// AdminNewsDetail shows a single news entry with full details
func AdminNewsDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load news
var news model.News
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").First(&news, newsID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get read statistics (extended version for global news support)
stats, _ := service.GetNewsReadStatsExtended(model.DB, uint(newsID))
// Format text as HTML using Markdown
htmlContent := util.RenderMarkdown(news.Text)
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByNews(uint(newsID))
util.RenderHTMLOK(c, "admin-news-detail.html", gin.H{
"lang": langStr,
"user": user,
"news": news,
"htmlContent": htmlContent,
"stats": stats,
"title": news.Title,
"attachments": attachments,
})
}
// AdminShowEditNews shows the form for editing news as admin
func AdminShowEditNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load news
var news model.News
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").First(&news, newsID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Determine current scope
scopeType := "global"
if news.GroupId != nil {
scopeType = "group"
} else if news.LocationId != nil {
scopeType = "location"
}
// Format valid until for form
var validUntilStr string
if news.ValidUntil != nil {
validUntilStr = news.ValidUntil.Format("2006-01-02")
}
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByNews(uint(newsID))
util.RenderHTMLOK(c, "admin-news-edit.html", gin.H{
"lang": langStr,
"user": user,
"news": news,
"scopeType": scopeType,
"validUntilStr": validUntilStr,
"title": "Edit News",
"attachments": attachments,
"maxAttachments": storage.MaxAttachmentsCount,
})
}
// AdminUpdateNews handles news update by admin
func AdminUpdateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load news
var news model.News
err = model.DB.First(&news, newsID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
title := c.PostForm("title")
text := c.PostForm("text")
validUntilStr := c.PostForm("valid_until")
// Validate required fields
if title == "" || text == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/news/"+newsIDStr+"/edit?error=missing_fields")
return
}
// Parse valid until date
var validUntil *time.Time
if validUntilStr != "" {
parsedTime, err := time.Parse("2006-01-02", validUntilStr)
if err == nil {
validUntil = &parsedTime
}
}
// Update news (scope cannot be changed)
news.Title = title
news.Text = text
news.ValidUntil = validUntil
if err := model.DB.Save(&news).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Associate any new AJAX-uploaded orphan attachments
attachmentIDStrs := c.PostFormArray("attachment_ids")
if len(attachmentIDStrs) > 0 {
var attachmentIDs []uint
for _, idStr := range attachmentIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
attachmentIDs = append(attachmentIDs, uint(parsed))
}
}
if len(attachmentIDs) > 0 {
fileStorage := storage.NewDBStorage(model.DB)
fileStorage.AssociateOrphansToNews(attachmentIDs, news.ID)
}
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/news/"+newsIDStr)
}
// AdminDeleteNews handles news deletion by admin
func AdminDeleteNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load news
var news model.News
err = model.DB.First(&news, newsID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Delete associated attachments before soft-deleting news
model.DB.Where("news_id = ?", news.ID).Delete(&model.Attachment{})
// Soft delete news
if err := model.DB.Delete(&news).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/news")
}
package controller
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminSessionSettings shows the session settings form
func AdminSessionSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
settings, err := model.GetSessionSettings()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-session-settings.html", gin.H{
"lang": langStr,
"user": user,
"settings": settings,
"success": c.Query("success"),
"error": c.Query("error"),
"title": "Session Settings",
})
}
// AdminSaveSessionSettings handles saving session settings
func AdminSaveSessionSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
shortMinutesStr := c.PostForm("short_session_minutes")
shortMinutes, err := strconv.Atoi(shortMinutesStr)
if err != nil || shortMinutes < 1 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/session-settings?error=invalid")
return
}
settings := &model.SessionSettings{
ShortSessionMinutes: shortMinutes,
}
if err := model.SaveSessionSettings(settings); err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/session-settings?error=save_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/session-settings?success=saved")
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminSyncDashboard shows the sync processing dashboard with pending counts and logs
func AdminSyncDashboard(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
// Get pending counts
pendingCounts, err := processor.GetPendingCounts()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
// Get recent processing logs
logs, err := processor.GetRecentLogs(10)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminToggleAutoSync handles POST to toggle the auto-sync setting
func AdminToggleAutoSync(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
autoSyncEnabled := c.PostForm("auto_sync_enabled") == "on"
settings, _ := model.GetSyncSettings()
settings.AutoSyncEnabled = autoSyncEnabled
model.SaveSyncSettings(settings)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/sync")
}
// AdminProcessAll processes all staging data in the correct order
func AdminProcessAll(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessAll()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessLocations processes only location data
func AdminProcessLocations(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessLocations()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessGroups processes only group data
func AdminProcessGroups(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessGroups()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessChildren processes only children data
func AdminProcessChildren(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessChildren()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessGroupLeads processes only group lead assignments
func AdminProcessGroupLeads(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessGroupLeads()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessLocationLeads processes only location lead assignments
func AdminProcessLocationLeads(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessLocationLeads()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
// AdminProcessChildGroups processes only child-group assignments
func AdminProcessChildGroups(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
processor := service.NewSyncProcessor(model.DB, user.ID)
result, _ := processor.ProcessChildGroups()
// Get updated pending counts
pendingCounts, _ := processor.GetPendingCounts()
// Get recent logs
logs, _ := processor.GetRecentLogs(10)
syncSettings, _ := model.GetSyncSettings()
receiveLogs, _ := processor.GetRecentReceiveLogs(10)
util.RenderHTMLOK(c, "admin-sync.html", gin.H{
"lang": langStr,
"user": user,
"pendingCounts": pendingCounts,
"logs": logs,
"result": result,
"syncSettings": syncSettings,
"receiveLogs": receiveLogs,
"title": "Sync Processing",
})
}
package controller
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// AdminTokensList shows all API tokens for admin management
func AdminTokensList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
var tokens []model.APIToken
model.DB.Preload("CreatedBy").Order("created_at DESC").Find(&tokens)
util.RenderHTMLOK(c, "admin-tokens-list.html", gin.H{
"lang": langStr,
"user": user,
"tokens": tokens,
"title": "API Token Management",
})
}
// AdminShowCreateToken shows the form to create a new API token
func AdminShowCreateToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-tokens-create.html", gin.H{
"lang": langStr,
"user": user,
"title": "Create API Token",
})
}
// AdminCreateToken handles the creation of a new API token
func AdminCreateToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
name := c.PostForm("name")
ipAllowlist := c.PostForm("ip_allowlist")
// Validate name
if name == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/tokens/new?error=missing_name")
return
}
// Generate secure random token (32 bytes = 64 hex chars)
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
plainToken := hex.EncodeToString(tokenBytes)
// Hash the token for storage
tokenHash, err := util.GenerateHashPassword(plainToken)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
apiToken := model.APIToken{
Name: name,
TokenHash: tokenHash,
IPAllowlist: ipAllowlist,
Active: true,
CreatedByID: user.ID,
}
if err := model.DB.Create(&apiToken).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
// Show the plain token ONCE to the user
util.RenderHTMLOK(c, "admin-tokens-created.html", gin.H{
"lang": langStr,
"user": user,
"token": &apiToken,
"plainToken": plainToken,
"title": "Token Created",
})
}
// AdminShowEditToken shows the form to edit an existing API token
func AdminShowEditToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
tokenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{
"lang": langStr,
"user": user,
"title": "Invalid Token ID",
})
return
}
var apiToken model.APIToken
if err := model.DB.Preload("CreatedBy").First(&apiToken, tokenID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"lang": langStr,
"user": user,
"title": "Token Not Found",
})
return
}
util.RenderHTMLOK(c, "admin-tokens-edit.html", gin.H{
"lang": langStr,
"user": user,
"token": &apiToken,
"title": "Edit API Token",
})
}
// AdminUpdateToken handles updating an existing API token
func AdminUpdateToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
tokenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{
"lang": langStr,
"user": user,
"title": "Invalid Token ID",
})
return
}
var apiToken model.APIToken
if err := model.DB.First(&apiToken, tokenID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"lang": langStr,
"user": user,
"title": "Token Not Found",
})
return
}
name := c.PostForm("name")
ipAllowlist := c.PostForm("ip_allowlist")
// Validate name
if name == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/tokens/"+c.Param("id")+"/edit?error=missing_name")
return
}
// Update fields
apiToken.Name = name
apiToken.IPAllowlist = ipAllowlist
if err := model.DB.Save(&apiToken).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/tokens")
}
// AdminRevokeToken disables an API token
func AdminRevokeToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
tokenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/tokens")
return
}
if err := model.DB.Model(&model.APIToken{}).Where("id = ?", tokenID).
Update("active", false).Error; err != nil {
// Log error but redirect anyway
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/tokens")
}
// AdminRegenerateToken creates a new token value for an existing token
func AdminRegenerateToken(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
tokenID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{
"lang": langStr,
"user": user,
"title": "Invalid Token ID",
})
return
}
var apiToken model.APIToken
if err := model.DB.First(&apiToken, tokenID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"lang": langStr,
"user": user,
"title": "Token Not Found",
})
return
}
// Generate new token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
plainToken := hex.EncodeToString(tokenBytes)
tokenHash, err := util.GenerateHashPassword(plainToken)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
apiToken.TokenHash = tokenHash
if err := model.DB.Save(&apiToken).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
"title": "Error",
})
return
}
// Show the new plain token ONCE to the user
util.RenderHTMLOK(c, "admin-tokens-created.html", gin.H{
"lang": langStr,
"user": user,
"token": &apiToken,
"plainToken": plainToken,
"regenerated": true,
"title": "Token Regenerated",
})
}
package controller
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// isUserAtLocations checks if a user is connected to any of the specified locations
// via group_teachers (as employee) or via children in groups at those locations (as parent)
func isUserAtLocations(db *gorm.DB, userID uint, locationIDs []uint) bool {
if len(locationIDs) == 0 {
return false
}
// Check if user is an employee at these locations
var employeeCount int64
db.Table("group_teachers").
Joins("JOIN groups ON group_teachers.group_id = groups.id").
Where("group_teachers.user_id = ?", userID).
Where("groups.location_id IN ?", locationIDs).
Count(&employeeCount)
if employeeCount > 0 {
return true
}
// Check if user has children at these locations
var parentCount int64
db.Table("user_children").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("user_children.user_id = ?", userID).
Where("groups.location_id IN ?", locationIDs).
Count(&parentCount)
return parentCount > 0
}
// AdminUsersList shows all users for admin management
func AdminUsersList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated users access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationUsers, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get LocationLead's locations for scoping (nil for admin = no restriction)
var allowedLocationIDs []uint
if !isAdmin && hasDelegatedAccess {
var err error
allowedLocationIDs, err = user.GetLeadLocationIDs(model.DB)
if err != nil || len(allowedLocationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Get admin location filter from context
var adminLocationId uint
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok {
adminLocationId = uint(id)
}
}
// Get search query
searchQuery := c.Query("q")
// Get sort parameters
sortField := c.DefaultQuery("sort", "email")
sortOrder := c.DefaultQuery("order", "asc")
// Validate sort field
validSortFields := map[string]bool{"email": true, "name": true, "status": true, "children": true, "role": true}
if !validSortFields[sortField] {
sortField = "email"
}
sortOption := service.UserSortOption{
Field: sortField,
Desc: sortOrder == "desc",
}
// Check for unassigned employees filter
filterUnassigned := c.Query("unassigned") == "true"
var users []model.User
var err error
// For LocationLeads, force location filter to their locations
if len(allowedLocationIDs) > 0 {
if searchQuery != "" {
// LocationLead with search: search within their locations
users, err = service.SearchUsersByLocations(model.DB, searchQuery, allowedLocationIDs, sortOption)
} else if len(allowedLocationIDs) == 1 {
users, err = service.GetUsersByLocation(model.DB, allowedLocationIDs[0], sortOption)
} else {
// Multiple locations - get users from all of them
users, err = service.GetUsersByLocations(model.DB, allowedLocationIDs, sortOption)
}
} else if searchQuery != "" {
// Search with optional location filter
var locationPtr *uint
if adminLocationId > 0 {
locationPtr = &adminLocationId
}
users, err = service.SearchUsers(model.DB, searchQuery, locationPtr, sortOption)
} else if filterUnassigned {
// Show employees without group assignments (overrides location filter)
users, err = service.GetEmployeesWithoutGroups(model.DB, sortOption)
} else if adminLocationId > 0 {
// Filter by location
users, err = service.GetUsersByLocation(model.DB, adminLocationId, sortOption)
} else {
// Get all users sorted
users, err = service.GetAllUsersSorted(model.DB, sortOption)
}
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-users-list.html", gin.H{
"lang": langStr,
"user": user,
"users": users,
"searchQuery": searchQuery,
"sortField": sortField,
"sortOrder": sortOrder,
"filterUnassigned": filterUnassigned,
"title": "User Management",
})
}
// AdminUserDetail shows detailed information about a single user
func AdminUserDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated users access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationUsers, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user with children info
targetUser, childrenInfo, err := service.GetUserWithChildren(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify the target user is at one of their locations
if !isAdmin && hasDelegatedAccess {
allowedLocationIDs, _ := user.GetLeadLocationIDs(model.DB)
if !isUserAtLocations(model.DB, uint(userID), allowedLocationIDs) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Load groups for staff users (Employee, GroupLead, LocationLead)
var userGroups []model.Group
isStaffUser := targetUser.IsEmployee() || targetUser.IsGroupLeader() || targetUser.IsHouseLeader()
if isStaffUser {
userGroups, _ = service.GetUserGroups(model.DB, uint(userID))
}
util.RenderHTMLOK(c, "admin-users-detail.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"childrenInfo": childrenInfo,
"userGroups": userGroups,
"isStaffUser": isStaffUser,
"title": targetUser.DisplayName(),
})
}
// AdminShowCreateUser shows the form for creating a new user
func AdminShowCreateUser(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Load all roles
allRoles, err := service.GetAllRoles(model.DB)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Filter out system roles (Anonymous)
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name != "Anonymous" {
assignableRoles = append(assignableRoles, role)
}
}
util.RenderHTMLOK(c, "admin-users-create.html", gin.H{
"lang": langStr,
"user": user,
"allRoles": assignableRoles,
"selectedRoleIDs": make(map[uint]bool),
"title": "Create User",
})
}
// AdminCreateUser handles user creation by admin
func AdminCreateUser(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
email := c.PostForm("email")
firstName := c.PostForm("first_name")
lastName := c.PostForm("last_name")
address := c.PostForm("address")
birthdayStr := c.PostForm("birthday")
language := c.PostForm("language")
if language == "" {
language = "de"
}
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm_password")
activated := c.PostForm("activated") == "on"
forcePasswordChange := c.PostForm("force_password_change") == "on"
// Parse role IDs from form
roleIDStrs := c.PostFormArray("roles[]")
var roleIDs []uint
for _, idStr := range roleIDStrs {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
roleIDs = append(roleIDs, uint(id))
}
}
// Helper function to re-render form with error
renderFormWithError := func(errorKey string) {
allRoles, _ := service.GetAllRoles(model.DB)
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name != "Anonymous" {
assignableRoles = append(assignableRoles, role)
}
}
// Preserve form values
selectedRoleIDs := make(map[uint]bool)
for _, id := range roleIDs {
selectedRoleIDs[id] = true
}
util.RenderHTML(c, http.StatusBadRequest, "admin-users-create.html", gin.H{
"lang": langStr,
"user": user,
"allRoles": assignableRoles,
"error": errorKey,
"formEmail": email,
"formFirstName": firstName,
"formLastName": lastName,
"formAddress": address,
"formBirthday": birthdayStr,
"formLanguage": language,
"formActivated": activated,
"formForcePassword": forcePasswordChange,
"selectedRoleIDs": selectedRoleIDs,
"title": "Create User",
})
}
// Validate email not empty
if email == "" {
renderFormWithError("admin.users.create.error.email_required")
return
}
// Validate email is not already taken
var existingUser model.User
if model.DB.Where("email = ?", email).First(&existingUser).Error == nil {
renderFormWithError("admin.users.create.error.email_exists")
return
}
// Validate password length
if len(password) < 10 {
renderFormWithError("admin.users.password.error.too_short")
return
}
// Validate password confirmation
if password != confirmPassword {
renderFormWithError("admin.users.password.error.mismatch")
return
}
// Create new user
newUser := model.User{
Email: email,
FirstName: firstName,
LastName: lastName,
Address: address,
PhoneNumber: strings.TrimSpace(c.PostForm("phone_number")),
Language: language,
Activated: activated,
ForcePasswordChange: forcePasswordChange,
}
// Parse birthday if provided
if birthdayStr != "" {
if parsed, err := time.Parse("2006-01-02", birthdayStr); err == nil {
newUser.Birthday = parsed
}
}
// Set activation timestamp if activated
if activated {
newUser.ActivatedAt = time.Now()
}
// Save user to database
if err := model.DB.Create(&newUser).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Create password hash
passHash, err := util.GenerateHashPassword(password)
if err != nil {
// Rollback: delete the user we just created
model.DB.Delete(&newUser)
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Create password record
passwd := model.Passwd{
UserId: newUser.ID,
PassHash: passHash,
}
if err := model.DB.Create(&passwd).Error; err != nil {
// Rollback: delete the user we just created
model.DB.Delete(&newUser)
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Assign roles
if len(roleIDs) > 0 {
if err := service.UpdateUserRoles(model.DB, newUser.ID, roleIDs); err != nil {
// Continue anyway - user is created, roles can be added later
}
}
// Redirect to user detail page
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+strconv.FormatUint(uint64(newUser.ID), 10))
}
// AdminShowEditUser shows the form for editing a user
func AdminShowEditUser(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated users access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationUsers, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
targetUser, err := service.GetUserByID(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify the target user is at one of their locations
if !isAdmin && hasDelegatedAccess {
allowedLocationIDs, _ := user.GetLeadLocationIDs(model.DB)
if !isUserAtLocations(model.DB, uint(userID), allowedLocationIDs) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Load all roles
allRoles, err := service.GetAllRoles(model.DB)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Filter out system roles (Anonymous) and build assignable roles list
// For LocationLeads, also filter out Admin role
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name == "Anonymous" {
continue
}
if !isAdmin && role.Name == "Admin" {
continue // LocationLeads cannot assign Admin role
}
assignableRoles = append(assignableRoles, role)
}
// Build a map of user's current role IDs for easy lookup in template
userRoleIDs := make(map[uint]bool)
for _, role := range targetUser.Roles {
userRoleIDs[role.ID] = true
}
// Format birthday for form input
var birthdayStr string
if !targetUser.Birthday.IsZero() {
birthdayStr = targetUser.Birthday.Format("2006-01-02")
}
util.RenderHTMLOK(c, "admin-users-edit.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"allRoles": assignableRoles,
"userRoleIDs": userRoleIDs,
"birthdayStr": birthdayStr,
"isDelegatedAccess": !isAdmin && hasDelegatedAccess,
"title": "Edit User",
})
}
// AdminUpdateUser handles user update by admin
func AdminUpdateUser(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check access: Admin or LocationLead with delegated users access
isAdmin := user.IsAdmin()
hasDelegatedAccess := user.CanAccessDelegatedAdmin(model.DB, model.DelegationUsers, nil)
if !isAdmin && !hasDelegatedAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
var targetUser model.User
err = model.DB.First(&targetUser, userID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// For LocationLeads, verify the target user is at one of their locations
if !isAdmin && hasDelegatedAccess {
allowedLocationIDs, _ := user.GetLeadLocationIDs(model.DB)
if !isUserAtLocations(model.DB, uint(userID), allowedLocationIDs) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
}
// Parse form data
firstName := c.PostForm("first_name")
lastName := c.PostForm("last_name")
address := c.PostForm("address")
birthdayStr := c.PostForm("birthday")
language := c.PostForm("language")
activated := c.PostForm("activated") == "on"
newEmail := strings.TrimSpace(strings.ToLower(c.PostForm("email")))
// Parse role IDs from form
roleIDStrs := c.PostFormArray("roles[]")
var roleIDs []uint
for _, idStr := range roleIDStrs {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
roleIDs = append(roleIDs, uint(id))
}
}
// Helper to re-render form with error (for email validation)
renderEditFormWithError := func(errorKey string) {
allRoles, _ := service.GetAllRoles(model.DB)
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name == "Anonymous" {
continue
}
if !isAdmin && role.Name == "Admin" {
continue
}
assignableRoles = append(assignableRoles, role)
}
model.DB.Preload("Roles").First(&targetUser, userID)
userRoleIDs := make(map[uint]bool)
for _, role := range targetUser.Roles {
userRoleIDs[role.ID] = true
}
util.RenderHTML(c, http.StatusBadRequest, "admin-users-edit.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"allRoles": assignableRoles,
"userRoleIDs": userRoleIDs,
"birthdayStr": birthdayStr,
"isDelegatedAccess": !isAdmin && hasDelegatedAccess,
"error": errorKey,
"title": "Edit User",
})
}
// Validate email
if newEmail == "" {
renderEditFormWithError("admin.users.email.error.empty")
return
}
// Check if email changed
if newEmail != targetUser.Email {
// Check uniqueness
var existingUser model.User
if model.DB.Where("email = ? AND id != ?", newEmail, targetUser.ID).First(&existingUser).Error == nil {
renderEditFormWithError("admin.users.email.error.taken")
return
}
targetUser.Email = newEmail
// Clear any pending email change since admin set the email directly
targetUser.PendingEmail = nil
targetUser.PendingEmailCode = nil
}
// Update basic fields
targetUser.FirstName = firstName
targetUser.LastName = lastName
targetUser.Address = address
targetUser.PhoneNumber = strings.TrimSpace(c.PostForm("phone_number"))
targetUser.Language = language
// Parse and set birthday
if birthdayStr != "" {
if parsedTime, err := time.Parse("2006-01-02", birthdayStr); err == nil {
targetUser.Birthday = parsedTime
}
}
// Handle activation status
if activated && !targetUser.Activated {
// Activating user
targetUser.Activated = true
targetUser.ActivatedAt = time.Now()
targetUser.ActivateCode = nil
} else if !activated && targetUser.Activated {
// Deactivating user
targetUser.Activated = false
targetUser.ActivatedAt = time.Time{}
}
// Handle deactivation status (admin only)
if isAdmin {
deactivated := c.PostForm("deactivated") == "on"
if deactivated && !targetUser.Deactivated {
// Deactivating user
now := time.Now()
targetUser.Deactivated = true
targetUser.DeactivatedAt = &now
targetUser.DeactivatedByID = &user.ID
} else if !deactivated && targetUser.Deactivated {
// Reactivating user
targetUser.Deactivated = false
targetUser.DeactivatedAt = nil
targetUser.DeactivatedByID = nil
}
}
// Save user
if err := model.DB.Save(&targetUser).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Update roles
if err := service.UpdateUserRoles(model.DB, uint(userID), roleIDs); err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Reconcile stand-ins (Stellvertretung) — revoke any that are no longer
// valid because the user was deactivated or lost their lead role.
if _, err := model.ReconcileStandInsForUser(model.DB, uint(userID)); err != nil {
// Non-fatal: log and continue. The save itself already succeeded.
// Stand-in cleanup will be retried on the next user update.
_ = err
}
// Handle password setting
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
forcePasswordChange := c.PostForm("force_password_change") == "on"
if newPassword != "" {
// Validate password length
if len(newPassword) < 10 {
// Reload data for form re-render
allRoles, _ := service.GetAllRoles(model.DB)
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name != "Anonymous" {
assignableRoles = append(assignableRoles, role)
}
}
model.DB.Preload("Roles").First(&targetUser, userID)
userRoleIDs := make(map[uint]bool)
for _, role := range targetUser.Roles {
userRoleIDs[role.ID] = true
}
util.RenderHTML(c, http.StatusBadRequest, "admin-users-edit.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"allRoles": assignableRoles,
"userRoleIDs": userRoleIDs,
"birthdayStr": birthdayStr,
"error": "admin.users.password.error.too_short",
"title": "Edit User",
})
return
}
// Validate password confirmation
if newPassword != confirmPassword {
allRoles, _ := service.GetAllRoles(model.DB)
var assignableRoles []model.Role
for _, role := range allRoles {
if role.Name != "Anonymous" {
assignableRoles = append(assignableRoles, role)
}
}
model.DB.Preload("Roles").First(&targetUser, userID)
userRoleIDs := make(map[uint]bool)
for _, role := range targetUser.Roles {
userRoleIDs[role.ID] = true
}
util.RenderHTML(c, http.StatusBadRequest, "admin-users-edit.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"allRoles": assignableRoles,
"userRoleIDs": userRoleIDs,
"birthdayStr": birthdayStr,
"error": "admin.users.password.error.mismatch",
"title": "Edit User",
})
return
}
// Hash new password
newHash, err := util.GenerateHashPassword(newPassword)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Update or create Passwd record
var passwd model.Passwd
result := model.DB.Where("user_id = ?", targetUser.ID).First(&passwd)
if result.Error != nil {
// Create new password record
passwd = model.Passwd{
UserId: targetUser.ID,
PassHash: newHash,
}
model.DB.Create(&passwd)
} else {
// Update existing
passwd.PassHash = newHash
model.DB.Save(&passwd)
}
// Set force password change flag if requested
if forcePasswordChange {
targetUser.ForcePasswordChange = true
model.DB.Save(&targetUser)
}
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
// ========== User-Child Relationship Management ==========
// AdminShowAddChild shows the form to add a child to a user
func AdminShowAddChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get target user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
targetUser, err := service.GetUserByID(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get admin location filter
var adminLocationId *uint
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
uid := uint(id)
adminLocationId = &uid
}
}
// Get available children (not yet assigned to this user)
availableChildren, err := service.GetAvailableChildrenForUser(model.DB, uint(userID), adminLocationId)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get relationship roles
relationshipRoles := service.GetAllRelationshipRoles()
util.RenderHTMLOK(c, "admin-users-add-child.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"availableChildren": availableChildren,
"relationshipRoles": relationshipRoles,
"title": "Add Child",
})
}
// AdminAddChild handles adding a child to a user
func AdminAddChild(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get target user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
childIDStr := c.PostForm("child_id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
relationshipRole := model.RelationshipRole(c.PostForm("relationship_role"))
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Parse dates
var validFrom, validUntil *time.Time
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
validFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
validUntil = &parsed
}
}
// Add the relationship
err = service.AddUserChildRelationship(model.DB, uint(userID), uint(childID), relationshipRole, validFrom, validUntil)
if err != nil {
log.Printf("AdminAddChild: failed to add child %d to user %d: %v", childID, userID, err)
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
// AdminShowEditChildRelation shows the form to edit a user-child relationship
func AdminShowEditChildRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("id")
childIDStr := c.Param("childid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
targetUser, err := service.GetUserByID(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Load relationship
userChild, err := service.GetUserChildRelationship(model.DB, uint(userID), uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Get relationship roles
relationshipRoles := service.GetAllRelationshipRoles()
// Format dates for form
var validFromStr, validUntilStr string
if userChild.ValidFrom != nil {
validFromStr = userChild.ValidFrom.Format("2006-01-02")
}
if userChild.ValidUntil != nil {
validUntilStr = userChild.ValidUntil.Format("2006-01-02")
}
util.RenderHTMLOK(c, "admin-users-edit-child.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"userChild": userChild,
"relationshipRoles": relationshipRoles,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": "Edit Child Relationship",
})
}
// AdminUpdateChildRelation handles updating a user-child relationship
func AdminUpdateChildRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("id")
childIDStr := c.Param("childid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
relationshipRole := model.RelationshipRole(c.PostForm("relationship_role"))
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Parse dates
var validFrom, validUntil *time.Time
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
validFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
validUntil = &parsed
}
}
// Update the relationship
err = service.UpdateUserChildRelationship(model.DB, uint(userID), uint(childID), relationshipRole, validFrom, validUntil)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
// AdminDeleteChildRelation handles removing a child from a user
func AdminDeleteChildRelation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("id")
childIDStr := c.Param("childid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Delete the relationship
err = service.DeleteUserChildRelationship(model.DB, uint(userID), uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
// ========== User-Group Management (Employee Group Assignment) ==========
// [impl->dsn~zugriffsmanagement-design~1]
// AdminShowAddGroup shows the form to add a group to an employee
func AdminShowAddGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get target user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
targetUser, err := service.GetUserByID(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Only show for staff users (Employee, GroupLead, LocationLead)
if !targetUser.IsEmployee() && !targetUser.IsGroupLeader() && !targetUser.IsHouseLeader() {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Get admin location filter
var adminLocationId *uint
if locID, exists := c.Get("adminLocationId"); exists {
if id, ok := locID.(int); ok && id > 0 {
uid := uint(id)
adminLocationId = &uid
}
}
// Get available groups (not yet assigned to this user)
availableGroups, err := service.GetAvailableGroupsForUser(model.DB, uint(userID), adminLocationId)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-users-add-group.html", gin.H{
"lang": langStr,
"user": user,
"targetUser": targetUser,
"availableGroups": availableGroups,
"title": "Add Group",
})
}
// AdminAddGroup handles adding a group to an employee
func AdminAddGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get target user ID from URL
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
groupIDStr := c.PostForm("group_id")
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Add the group assignment
err = service.AddUserToGroup(model.DB, uint(userID), uint(groupID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
// AdminDeleteGroup handles removing a group from an employee
func AdminDeleteGroup(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("id")
groupIDStr := c.Param("groupid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Remove the group assignment
err = service.RemoveUserFromGroup(model.DB, uint(userID), uint(groupID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users/"+userIDStr)
}
package controller
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// Archive handles the main archive page with tabs for news, letters, messages, absences.
func Archive(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Check access - only employees and admins
if !service.CanUserAccessArchive(user) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get admin location if set
var adminLocationId uint
if user.IsAdmin() {
if adminLocId, ok := c.Get("adminLocationId"); ok {
if locId, ok := adminLocId.(int); ok && locId > 0 {
adminLocationId = uint(locId)
}
}
}
// Parse filters from query params
filters := parseArchiveFilters(c)
// Get accessible groups
groupIDs, err := service.GetArchiveAccessibleGroups(model.DB, user, adminLocationId)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get accessible groups"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get groups for filter dropdown
filterGroups, err := service.GetGroupsForArchiveFilter(model.DB, user, adminLocationId)
if err != nil {
filterGroups = []model.Group{}
}
// Get tab counts
counts, err := service.GetArchiveTabCounts(model.DB, groupIDs, filters)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get counts"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get current tab from query
tab := c.DefaultQuery("tab", "news")
// Fetch items for current tab
var news []model.News
var letters []model.ParentalLetter
var messages []model.Message
var absences []model.AbsenceNotification
switch tab {
case "letters":
letters, err = service.GetArchivedLetters(model.DB, groupIDs, filters)
case "messages":
messages, err = service.GetArchivedMessages(model.DB, groupIDs, filters)
case "absences":
absences, err = service.GetArchivedAbsences(model.DB, groupIDs, filters)
default:
tab = "news"
news, err = service.GetArchivedNews(model.DB, groupIDs, filters)
}
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch items"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Determine last item ID for infinite scroll
var lastID uint
switch tab {
case "letters":
if len(letters) > 0 {
lastID = letters[len(letters)-1].ID
}
case "messages":
if len(messages) > 0 {
lastID = messages[len(messages)-1].ID
}
case "absences":
if len(absences) > 0 {
lastID = absences[len(absences)-1].ID
}
default:
if len(news) > 0 {
lastID = news[len(news)-1].ID
}
}
// Check if there are more items
hasMore := false
switch tab {
case "letters":
hasMore = int64(len(letters)) == int64(filters.Limit)
case "messages":
hasMore = int64(len(messages)) == int64(filters.Limit)
case "absences":
hasMore = int64(len(absences)) == int64(filters.Limit)
default:
hasMore = int64(len(news)) == int64(filters.Limit)
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"tab": tab,
"counts": counts,
"news": news,
"letters": letters,
"messages": messages,
"absences": absences,
"lastID": lastID,
"hasMore": hasMore,
})
return
}
util.RenderHTMLOK(c, "archive.html", gin.H{
"lang": langStr,
"user": user,
"title": "Archive",
"tab": tab,
"counts": counts,
"news": news,
"letters": letters,
"messages": messages,
"absences": absences,
"filterGroups": filterGroups,
"filters": gin.H{
"search": filters.Search,
"dateFrom": formatDateFilter(filters.DateFrom),
"dateTo": formatDateFilter(filters.DateTo),
"groupID": filters.GroupID,
"sortAsc": filters.SortAsc,
},
"lastID": lastID,
"hasMore": hasMore,
})
}
// ArchiveLoadMore handles infinite scroll loading of additional items.
func ArchiveLoadMore(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
return
}
user := userInterface.(*model.User)
// Check access
if !service.CanUserAccessArchive(user) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
// Get admin location if set
var adminLocationId uint
if user.IsAdmin() {
if adminLocId, ok := c.Get("adminLocationId"); ok {
if locId, ok := adminLocId.(int); ok && locId > 0 {
adminLocationId = uint(locId)
}
}
}
// Parse filters from query params
filters := parseArchiveFilters(c)
// Get accessible groups
groupIDs, err := service.GetArchiveAccessibleGroups(model.DB, user, adminLocationId)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get accessible groups"})
return
}
// Get current tab
tab := c.DefaultQuery("tab", "news")
// Fetch items for current tab
var items interface{}
var lastID uint
switch tab {
case "letters":
letters, err := service.GetArchivedLetters(model.DB, groupIDs, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch items"})
return
}
items = letters
if len(letters) > 0 {
lastID = letters[len(letters)-1].ID
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"items": letters,
"lastID": lastID,
"hasMore": len(letters) == filters.Limit,
"lang": langStr,
})
case "messages":
messages, err := service.GetArchivedMessages(model.DB, groupIDs, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch items"})
return
}
items = messages
if len(messages) > 0 {
lastID = messages[len(messages)-1].ID
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"items": messages,
"lastID": lastID,
"hasMore": len(messages) == filters.Limit,
"lang": langStr,
})
case "absences":
absences, err := service.GetArchivedAbsences(model.DB, groupIDs, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch items"})
return
}
items = absences
if len(absences) > 0 {
lastID = absences[len(absences)-1].ID
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"items": absences,
"lastID": lastID,
"hasMore": len(absences) == filters.Limit,
"lang": langStr,
})
default:
news, err := service.GetArchivedNews(model.DB, groupIDs, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch items"})
return
}
items = news
if len(news) > 0 {
lastID = news[len(news)-1].ID
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"items": news,
"lastID": lastID,
"hasMore": len(news) == filters.Limit,
"lang": langStr,
})
}
// This is just to use items to avoid unused variable error
_ = items
}
// parseArchiveFilters parses filter parameters from the request.
func parseArchiveFilters(c *gin.Context) service.ArchiveFilters {
filters := service.ArchiveFilters{
Search: c.Query("search"),
Limit: 20,
SortAsc: c.Query("sort") == "oldest",
}
// Parse date_from
if dateFromStr := c.Query("date_from"); dateFromStr != "" {
if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil {
filters.DateFrom = &dateFrom
}
}
// Parse date_to
if dateToStr := c.Query("date_to"); dateToStr != "" {
if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil {
filters.DateTo = &dateTo
}
}
// Parse group_id
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
if groupID, err := strconv.ParseUint(groupIDStr, 10, 32); err == nil {
filters.GroupID = uint(groupID)
}
}
// Parse before_id for pagination
if beforeIDStr := c.Query("before_id"); beforeIDStr != "" {
if beforeID, err := strconv.ParseUint(beforeIDStr, 10, 32); err == nil {
filters.BeforeID = uint(beforeID)
}
}
// Parse limit
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit <= 100 {
filters.Limit = limit
}
}
return filters
}
// formatDateFilter formats a date for the filter input value.
func formatDateFilter(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02")
}
package controller
// Auth controller - handles login/logout for both HTML and JSON API
// Supports dual-frontend architecture with cookie and JWT authentication
//
// [impl->dsn~dual-api-architektur~1]
// [impl->dsn~rechtevergabe-design~1]
import (
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v5"
)
type LoginUser struct {
Email string `form:"Email" binding:"required,email"`
Password string `form:"Password" binding:"required"`
SessionMode string `form:"session_mode"`
}
func Login(c *gin.Context) {
isJSON := shouldReturnJSON(c)
if c.Request.Method == http.MethodPost {
var loginUser LoginUser
if err := c.ShouldBind(&loginUser); err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_INPUT",
"message": err.Error(),
},
})
} else {
c.JSON(400, gin.H{"error": err.Error()})
}
return
}
var existingUser model.User
var existingPassword model.Passwd
// Normalize email to match registration (lowercase + trim)
normalizedEmail := strings.ToLower(strings.TrimSpace(loginUser.Email))
model.DB.Where("email = ?", normalizedEmail).First(&existingUser)
if existingUser.ID == 0 {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_CREDENTIALS",
"message": "Invalid email or password",
},
})
} else {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"title": "login.title",
"lang": "de",
"user": model.NewDummyUser(),
"error": "login.error.invalid_credentials",
})
}
return
}
// Block deactivated users before password check
if existingUser.Deactivated {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "ACCOUNT_DEACTIVATED",
"message": "Account has been deactivated",
},
})
} else {
c.HTML(http.StatusForbidden, "login.html", gin.H{
"title": "login.title",
"lang": "de",
"user": model.NewDummyUser(),
"error": "login.error.account_deactivated",
})
}
return
}
model.DB.Where("user_id = ?", existingUser.ID).First(&existingPassword)
errHash := util.CompareHashPassword(loginUser.Password, existingPassword.PassHash)
if !errHash {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_CREDENTIALS",
"message": "Invalid email or password",
},
})
} else {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"title": "login.title",
"lang": "de",
"user": model.NewDummyUser(),
"error": "login.error.invalid_credentials",
})
}
return
}
// Determine session duration based on session_mode
var expirationTime time.Time
var cookieMaxAge int
if loginUser.SessionMode == "persistent" {
// Persistent login: 7 days
expirationTime = time.Now().Add(7 * 24 * time.Hour)
cookieMaxAge = 86400 * 7
} else {
// Short login (default): configurable duration
sessionSettings, _ := model.GetSessionSettings()
shortMinutes := 60
if sessionSettings != nil && sessionSettings.ShortSessionMinutes > 0 {
shortMinutes = sessionSettings.ShortSessionMinutes
}
expirationTime = time.Now().Add(time.Duration(shortMinutes) * time.Minute)
cookieMaxAge = shortMinutes * 60
}
claims := &model.Claims{
UserIdent: existingUser.ID,
UserEmail: existingUser.Email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: existingUser.Email,
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
secret, isset := os.LookupEnv("APP_SECRET")
if !isset {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
c.JSON(500, gin.H{"error": "could not generate token"})
}
return
}
key, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
c.JSON(500, gin.H{"error": "could not generate token"})
}
return
}
tokenString, err := token.SignedString(key)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
c.JSON(500, gin.H{"error": "could not generate token"})
}
return
}
// Set cookie for both HTML and JSON (for session-based auth)
c.SetCookie(
"token",
tokenString,
cookieMaxAge,
"/",
"",
util.SecureCookies(),
true,
)
c.Set("User", &existingUser)
// Check if user needs to change password
if existingUser.ForcePasswordChange {
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"token": tokenString,
"user": existingUser,
"forcePasswordChange": true,
"redirectUrl": "/settings/password",
},
})
return
}
// HTML: Redirect to password change page
lang := existingUser.Language
if lang == "" {
lang = "de"
}
c.Redirect(http.StatusSeeOther, "/"+lang+"/settings/password")
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"token": tokenString,
"user": existingUser,
},
})
return
}
c.Redirect(http.StatusSeeOther, "/")
return
}
// GET request or other methods
if isJSON {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"success": false,
"error": gin.H{
"code": "METHOD_NOT_ALLOWED",
"message": "Only POST method is allowed",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/")
}
}
func Logout(c *gin.Context) {
isJSON := shouldReturnJSON(c)
c.Set("User", nil)
c.SetCookie("token", "", -1, "/", "", util.SecureCookies(), true)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": "Successfully logged out",
},
})
return
}
c.Redirect(http.StatusSeeOther, "/")
}
package controller
// Calendar controller - handles calendar events for groups and locations
// Group leaders, location leaders, and admins can create events, everyone can view them
//
// [impl->dsn~gui-kalender~1]
import (
"html/template"
"log"
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// CalendarDay represents a single day in the calendar grid
type CalendarDay struct {
Date time.Time
DayNum int
IsOtherMonth bool
IsToday bool
WeekdayKey string // Translation key for weekday name (e.g., "calendar.day.mon")
Events []EventWithPermissions
}
// weekdayToKey maps Go's time.Weekday to translation keys
var weekdayToKey = map[time.Weekday]string{
time.Sunday: "calendar.day.sun",
time.Monday: "calendar.day.mon",
time.Tuesday: "calendar.day.tue",
time.Wednesday: "calendar.day.wed",
time.Thursday: "calendar.day.thu",
time.Friday: "calendar.day.fri",
time.Saturday: "calendar.day.sat",
}
// CalendarWeek represents a week in the month view
type CalendarWeek struct {
Days []CalendarDay
}
// CalendarData holds the data for rendering the calendar grid
type CalendarData struct {
Weeks []CalendarWeek // For month view
Days []CalendarDay // For week view
}
// EventWithPermissions wraps an event with edit permission info
type EventWithPermissions struct {
Event model.CalendarEvent
CanEdit bool
}
// Calendar displays the calendar view
func Calendar(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get view type from query params (default: month)
viewType := c.DefaultQuery("view", "month")
if viewType != "week" && viewType != "month" && viewType != "day" {
viewType = "month"
}
// Get date from query params (default: today)
dateStr := c.Query("date")
var targetDate time.Time
if dateStr != "" {
parsed, err := time.Parse("2006-01-02", dateStr)
if err == nil {
targetDate = parsed
} else {
targetDate = time.Now()
}
} else {
targetDate = time.Now()
}
// Calculate date range based on view type
var startDate, endDate time.Time
switch viewType {
case "day":
// Day view: single day
startDate = targetDate
endDate = targetDate
case "week":
// Week view: Monday to Sunday (European standard)
weekday := int(targetDate.Weekday())
if weekday == 0 {
weekday = 7 // Sunday becomes 7
}
startDate = targetDate.AddDate(0, 0, -(weekday - 1)) // Go to Monday
endDate = startDate.AddDate(0, 0, 6) // Go to Sunday
default:
// Month view: first to last day of month
year, month, _ := targetDate.Date()
startDate = time.Date(year, month, 1, 0, 0, 0, 0, targetDate.Location())
endDate = startDate.AddDate(0, 1, -1)
}
// Get user's accessible groups
groupIds := service.GetUserGroupIds(model.DB, user)
// Get admin location filter if applicable
var adminLocId *uint
if adminLocIdVal, exists := c.Get("adminLocationId"); exists {
if locId, ok := adminLocIdVal.(int); ok && locId > 0 {
locIdUint := uint(locId)
adminLocId = &locIdUint
}
}
// Fetch events
events, err := service.GetEventsForDateRange(model.DB, user, startDate, endDate, adminLocId, groupIds)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Add permissions to events
var eventsWithPermissions []EventWithPermissions
for _, event := range events {
eventsWithPermissions = append(eventsWithPermissions, EventWithPermissions{
Event: event,
CanEdit: service.CanUserEditCalendarEvent(model.DB, user, &event),
})
}
// Check if user can create events
canCreate := user.IsGroupLeader() || user.IsHouseLeader() || user.IsAdmin()
// Compute group abbreviations for scope display
groupIDSet := make(map[uint]bool)
for _, ev := range events {
if ev.GroupId != nil {
groupIDSet[*ev.GroupId] = true
}
}
var groupIDs []uint
for id := range groupIDSet {
groupIDs = append(groupIDs, id)
}
groupAbbrevs := service.GetCalendarGroupAbbreviations(model.DB, groupIDs)
// Generate calendar grid data
calendarData := generateCalendarData(viewType, targetDate, startDate, endDate, eventsWithPermissions)
// Calculate prev/next dates
prevDate := calculatePrevDate(viewType, targetDate)
nextDate := calculateNextDate(viewType, targetDate)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"events": eventsWithPermissions,
"viewType": viewType,
"targetDate": targetDate.Format("2006-01-02"),
"startDate": startDate.Format("2006-01-02"),
"endDate": endDate.Format("2006-01-02"),
"prevDate": prevDate.Format("2006-01-02"),
"nextDate": nextDate.Format("2006-01-02"),
"canCreate": canCreate,
})
} else {
templateName := "calendar.html"
templateData := gin.H{
"lang": langStr,
"user": user,
"title": "calendar.title",
"viewType": viewType,
"targetDate": targetDate,
"startDate": startDate,
"endDate": endDate,
"events": eventsWithPermissions,
"calendarData": calendarData,
"canCreate": canCreate,
"prevDate": prevDate,
"nextDate": nextDate,
"groupAbbrevs": groupAbbrevs,
}
if viewType == "day" {
templateName = "calendar-day.html"
templateData["weekdayKey"] = weekdayToKey[targetDate.Weekday()]
templateData["formattedDate"] = targetDate.Format("02.01.2006")
}
util.RenderHTMLOK(c, templateName, templateData)
}
}
// ShowCreateCalendarEvent displays the event creation form
func ShowCreateCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLeaders, LocationLeaders, and Admins can create events
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get groups user can create events for
groups, _ := service.GetUserCreatableGroups(model.DB, user)
locations, _ := service.GetUserCreatableLocations(model.DB, user)
// Pre-fill date from query param
dateStr := c.Query("date")
var defaultDate string
if dateStr != "" {
defaultDate = dateStr
} else {
defaultDate = time.Now().Format("2006-01-02")
}
// Determine default reminder checkbox from location settings
defaultReminder := true
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
if user.IsHouseLeader() || isAdminWithLocation {
var locationID uint
if isAdminWithLocation {
locationID = uint(adminLocId.(int))
} else {
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
locationID = locationIDs[0]
}
}
if locationID > 0 {
var loc model.Location
if err := model.DB.First(&loc, locationID).Error; err == nil {
defaultReminder = loc.DefaultEventReminder
}
}
}
util.RenderHTMLOK(c, "calendar-event-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "calendar.event.create",
"groups": groups,
"locations": locations,
"defaultDate": defaultDate,
"isEdit": false,
"defaultReminder": defaultReminder,
})
}
// CreateCalendarEvent handles the creation of new events
func CreateCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse form data
title := c.PostForm("title")
description := c.PostForm("description")
eventType := c.PostForm("event_type")
startDateStr := c.PostForm("start_date")
endDateStr := c.PostForm("end_date")
allDayStr := c.PostForm("all_day")
scopeType := c.PostForm("scope_type") // "global", "location", or "group"
groupIDStr := c.PostForm("group_id")
locationIDStr := c.PostForm("location_id")
employeeOnly := c.PostForm("employee_only") == "true"
// Validate required fields
if title == "" || startDateStr == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "title and start date required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=missing_fields")
}
return
}
allDay := allDayStr == "on" || allDayStr == "true" || allDayStr == "1"
// Parse dates (and times if not all-day)
var startDate, endDate time.Time
var err error
if allDay {
startDate, err = time.Parse("2006-01-02", startDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid start date"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=invalid_date")
}
return
}
endDate = startDate
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = parsed
}
}
} else {
// Parse date + time
startTimeStr := c.PostForm("start_time")
endTimeStr := c.PostForm("end_time")
if startTimeStr == "" {
startTimeStr = "09:00"
}
if endTimeStr == "" {
endTimeStr = "17:00"
}
startDate, err = time.Parse("2006-01-02 15:04", startDateStr+" "+startTimeStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid start date/time"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=invalid_date")
}
return
}
endDateToUse := endDateStr
if endDateToUse == "" {
endDateToUse = startDateStr
}
endDate, err = time.Parse("2006-01-02 15:04", endDateToUse+" "+endTimeStr)
if err != nil {
endDate = startDate.Add(time.Hour) // Default 1 hour duration
}
}
// Validate date range
if endDate.Before(startDate) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "end date before start date"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=invalid_date_range")
}
return
}
// Validate event type
validEventType := model.EventTypeOther
switch model.EventType(eventType) {
case model.EventTypeActivity, model.EventTypeClosure, model.EventTypeHoliday, model.EventTypeMeeting:
validEventType = model.EventType(eventType)
}
// Determine scope
var locationId *uint
var groupId *uint
switch scopeType {
case "global":
// Global event - only admins can create
if !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// locationId and groupId stay nil
case "location":
// Location-wide event
if locationIDStr == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "location required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=missing_location")
}
return
}
parsedLocID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid location ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=invalid_location")
}
return
}
locIdUint := uint(parsedLocID)
locationId = &locIdUint
case "group":
fallthrough
default:
// Group-specific event
if groupIDStr == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "group required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=missing_group")
}
return
}
parsedGroupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid group ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=invalid_group")
}
return
}
groupIdUint := uint(parsedGroupID)
groupId = &groupIdUint
// Get group's location
var group model.Group
if err := model.DB.First(&group, groupIdUint).Error; err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "group not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/new?error=group_not_found")
}
return
}
locationId = &group.LocationId
}
// Verify permissions
if !service.CanUserCreateCalendarEvent(model.DB, user, groupId, locationId) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse reminder checkbox
sendReminderStr := c.PostForm("send_reminder")
sendReminder := sendReminderStr == "on" || sendReminderStr == "true" || sendReminderStr == "1"
// Create event
event := model.CalendarEvent{
Title: title,
Description: description,
EventType: validEventType,
StartDate: startDate,
EndDate: endDate,
AllDay: allDay,
LocationId: locationId,
GroupId: groupId,
CreatedById: user.ID,
SendReminder: sendReminder,
EmployeeOnly: employeeOnly,
}
if err := service.CreateCalendarEvent(model.DB, &event); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create event"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"event": event,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar?date="+startDate.Format("2006-01-02"))
}
}
// GetCalendarEventDetail shows a single event
func GetCalendarEventDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid event ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "event not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Render markdown description
var htmlContent template.HTML
if event.Description != "" {
htmlContent = util.RenderMarkdown(event.Description)
}
canEdit := service.CanUserEditCalendarEvent(model.DB, user, event)
// Query latest news posted for this event
var lastNewsPostedAt *time.Time
canPostNewsToday := true
var latestNews model.News
if err := model.DB.Where("calendar_event_id = ?", event.ID).Order("published_at DESC").First(&latestNews).Error; err == nil {
lastNewsPostedAt = &latestNews.PublishedAt
// Check if already posted today
today := time.Now().Truncate(24 * time.Hour)
if latestNews.PublishedAt.After(today) || latestNews.PublishedAt.Equal(today) {
canPostNewsToday = false
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"event": event,
"htmlContent": htmlContent,
"canEdit": canEdit,
"lastNewsPostedAt": lastNewsPostedAt,
"canPostNewsToday": canPostNewsToday,
})
} else {
util.RenderHTMLOK(c, "calendar-event-detail.html", gin.H{
"lang": langStr,
"user": user,
"title": event.Title,
"event": event,
"htmlContent": htmlContent,
"canEdit": canEdit,
"lastNewsPostedAt": lastNewsPostedAt,
"canPostNewsToday": canPostNewsToday,
})
}
}
// PostNewsForEvent manually publishes a news post for a calendar event.
// Rate-limited to one post per event per day.
func PostNewsForEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid event ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "event not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Check edit permissions
if !service.CanUserEditCalendarEvent(model.DB, user, event) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Cannot post news for cancelled events
if event.Cancelled {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "event is cancelled"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr)
}
return
}
// Rate limit: check if news was already posted today for this event
today := time.Now().Truncate(24 * time.Hour)
var todayCount int64
model.DB.Model(&model.News{}).
Where("calendar_event_id = ? AND published_at >= ?", event.ID, today).
Count(&todayCount)
if todayCount > 0 {
if isJSON {
c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": "already posted today"})
} else {
// Redirect back with rate limit info - render detail page with error
var htmlContent template.HTML
if event.Description != "" {
htmlContent = util.RenderMarkdown(event.Description)
}
var lastNewsPostedAt *time.Time
var latestNews model.News
if err := model.DB.Where("calendar_event_id = ?", event.ID).Order("published_at DESC").First(&latestNews).Error; err == nil {
lastNewsPostedAt = &latestNews.PublishedAt
}
util.RenderHTMLOK(c, "calendar-event-detail.html", gin.H{
"lang": langStr,
"user": user,
"title": event.Title,
"event": event,
"htmlContent": htmlContent,
"canEdit": true,
"lastNewsPostedAt": lastNewsPostedAt,
"canPostNewsToday": false,
"error": "calendar.event.postnews.ratelimited",
})
}
return
}
// Generate news from event
news, err := service.GenerateNewsFromCalendarEvent(model.DB, event, langStr)
if err != nil {
log.Printf("[CALENDAR] Failed to generate news for event %d: %v", event.ID, err)
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create news"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Send email notifications
service.NotifyNewsPublished(model.DB, news)
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"news": news,
})
} else {
// Render detail page with success message
var htmlContent template.HTML
if event.Description != "" {
htmlContent = util.RenderMarkdown(event.Description)
}
lastNewsPostedAt := &news.PublishedAt
util.RenderHTMLOK(c, "calendar-event-detail.html", gin.H{
"lang": langStr,
"user": user,
"title": event.Title,
"event": event,
"htmlContent": htmlContent,
"canEdit": true,
"lastNewsPostedAt": lastNewsPostedAt,
"canPostNewsToday": false,
"success": "calendar.event.postnews.success",
})
}
}
// ShowEditCalendarEvent displays the event edit form
func ShowEditCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
return
}
// Check permissions
if !service.CanUserEditCalendarEvent(model.DB, user, event) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get groups and locations for the form
groups, _ := service.GetUserCreatableGroups(model.DB, user)
locations, _ := service.GetUserCreatableLocations(model.DB, user)
util.RenderHTMLOK(c, "calendar-event-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "calendar.event.edit",
"event": event,
"groups": groups,
"locations": locations,
"isEdit": true,
})
}
// UpdateCalendarEvent handles event updates
func UpdateCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid event ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "event not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Check permissions
if !service.CanUserEditCalendarEvent(model.DB, user, event) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form data
title := c.PostForm("title")
description := c.PostForm("description")
eventType := c.PostForm("event_type")
startDateStr := c.PostForm("start_date")
endDateStr := c.PostForm("end_date")
allDayStr := c.PostForm("all_day")
// Validate required fields
if title == "" || startDateStr == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "title and start date required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr+"/edit?error=missing_fields")
}
return
}
allDay := allDayStr == "on" || allDayStr == "true" || allDayStr == "1"
// Parse dates (and times if not all-day)
var startDate, endDate time.Time
if allDay {
startDate, err = time.Parse("2006-01-02", startDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid start date"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr+"/edit?error=invalid_date")
}
return
}
endDate = startDate
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = parsed
}
}
} else {
// Parse date + time
startTimeStr := c.PostForm("start_time")
endTimeStr := c.PostForm("end_time")
if startTimeStr == "" {
startTimeStr = "09:00"
}
if endTimeStr == "" {
endTimeStr = "17:00"
}
startDate, err = time.Parse("2006-01-02 15:04", startDateStr+" "+startTimeStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid start date/time"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr+"/edit?error=invalid_date")
}
return
}
endDateToUse := endDateStr
if endDateToUse == "" {
endDateToUse = startDateStr
}
endDate, err = time.Parse("2006-01-02 15:04", endDateToUse+" "+endTimeStr)
if err != nil {
endDate = startDate.Add(time.Hour) // Default 1 hour duration
}
}
// Validate date range
if endDate.Before(startDate) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "end date before start date"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr+"/edit?error=invalid_date_range")
}
return
}
// Validate event type
validEventType := event.EventType
switch model.EventType(eventType) {
case model.EventTypeActivity, model.EventTypeClosure, model.EventTypeHoliday, model.EventTypeMeeting, model.EventTypeOther:
validEventType = model.EventType(eventType)
}
// Update event (keep scope unchanged)
event.Title = title
event.Description = description
event.EventType = validEventType
event.StartDate = startDate
event.EndDate = endDate
event.AllDay = allDay
if err := service.UpdateCalendarEvent(model.DB, event); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update event"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"event": event,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr)
}
}
// CancelCalendarEvent marks an event as cancelled
func CancelCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid event ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "event not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Check permissions
if !service.CanUserEditCalendarEvent(model.DB, user, event) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Cancel event
if err := service.CancelCalendarEvent(model.DB, event, user.ID); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to cancel event"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"event": event,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar/event/"+idStr)
}
}
// DeleteCalendarEvent handles event deletion
func DeleteCalendarEvent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse event ID
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid event ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Get event
event, err := service.GetCalendarEventByID(model.DB, uint(id))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "event not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
return
}
// Check permissions
if !service.CanUserDeleteCalendarEvent(model.DB, user, event) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Delete event
if err := service.DeleteCalendarEvent(model.DB, event); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to delete event"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/calendar")
}
}
// Helper functions for calendar grid generation
func generateCalendarData(viewType string, target, start, end time.Time, events []EventWithPermissions) CalendarData {
today := time.Now()
todayStr := today.Format("2006-01-02")
// Create a map of events by date for quick lookup
eventsByDate := make(map[string][]EventWithPermissions)
for _, ev := range events {
// Add event to each day it spans
current := ev.Event.StartDate
for !current.After(ev.Event.EndDate) {
dateKey := current.Format("2006-01-02")
eventsByDate[dateKey] = append(eventsByDate[dateKey], ev)
current = current.AddDate(0, 0, 1)
}
}
switch viewType {
case "day":
return generateDayData(target, todayStr, eventsByDate)
case "week":
return generateWeekData(start, todayStr, eventsByDate)
default:
return generateMonthData(target, todayStr, eventsByDate)
}
}
func generateDayData(target time.Time, todayStr string, eventsByDate map[string][]EventWithPermissions) CalendarData {
dateStr := target.Format("2006-01-02")
day := CalendarDay{
Date: target,
DayNum: target.Day(),
IsToday: dateStr == todayStr,
WeekdayKey: weekdayToKey[target.Weekday()],
Events: eventsByDate[dateStr],
}
return CalendarData{Days: []CalendarDay{day}}
}
func generateWeekData(start time.Time, todayStr string, eventsByDate map[string][]EventWithPermissions) CalendarData {
var days []CalendarDay
for i := 0; i < 7; i++ {
date := start.AddDate(0, 0, i)
dateStr := date.Format("2006-01-02")
days = append(days, CalendarDay{
Date: date,
DayNum: date.Day(),
IsToday: dateStr == todayStr,
WeekdayKey: weekdayToKey[date.Weekday()],
Events: eventsByDate[dateStr],
})
}
return CalendarData{Days: days}
}
func generateMonthData(target time.Time, todayStr string, eventsByDate map[string][]EventWithPermissions) CalendarData {
var weeks []CalendarWeek
year, month, _ := target.Date()
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, target.Location())
lastOfMonth := firstOfMonth.AddDate(0, 1, -1)
// Find the Monday before or on the first of the month
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7 // Sunday becomes 7
}
gridStart := firstOfMonth.AddDate(0, 0, -(weekday - 1))
// Generate 6 weeks of days (to cover all months)
current := gridStart
for w := 0; w < 6; w++ {
var week CalendarWeek
for d := 0; d < 7; d++ {
dateStr := current.Format("2006-01-02")
isOtherMonth := current.Before(firstOfMonth) || current.After(lastOfMonth)
week.Days = append(week.Days, CalendarDay{
Date: current,
DayNum: current.Day(),
IsOtherMonth: isOtherMonth,
IsToday: dateStr == todayStr,
WeekdayKey: weekdayToKey[current.Weekday()],
Events: eventsByDate[dateStr],
})
current = current.AddDate(0, 0, 1)
}
weeks = append(weeks, week)
// Stop if we've passed the end of the month and finished the week
if current.After(lastOfMonth) && current.Weekday() == time.Monday {
break
}
}
return CalendarData{Weeks: weeks}
}
func calculatePrevDate(viewType string, target time.Time) time.Time {
switch viewType {
case "day":
return target.AddDate(0, 0, -1)
case "week":
return target.AddDate(0, 0, -7)
default:
return target.AddDate(0, -1, 0)
}
}
func calculateNextDate(viewType string, target time.Time) time.Time {
switch viewType {
case "day":
return target.AddDate(0, 0, 1)
case "week":
return target.AddDate(0, 0, 7)
default:
return target.AddDate(0, 1, 0)
}
}
package controller
import (
"html/template"
"log"
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ChangelogView shows all changelog entries (requires login)
func ChangelogView(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get all changelog entries ordered by release date (newest first)
var entries []model.ChangelogEntry
if err := model.DB.Order("release_date DESC, created_at DESC").Find(&entries).Error; err != nil {
log.Printf("Error loading changelog entries: %v", err)
}
// Render markdown for each entry
type ChangelogDisplay struct {
model.ChangelogEntry
ChangesHTML template.HTML
}
var displayEntries []ChangelogDisplay
for _, entry := range entries {
displayEntries = append(displayEntries, ChangelogDisplay{
ChangelogEntry: entry,
ChangesHTML: util.RenderMarkdown(entry.Changes),
})
}
util.RenderHTMLOK(c, "changelog.html", gin.H{
"lang": langStr,
"user": user,
"entries": displayEntries,
"title": "Changelog",
})
}
// AdminChangelogList shows all changelog entries for management
func AdminChangelogList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get all changelog entries with creator info
var entries []model.ChangelogEntry
if err := model.DB.Preload("CreatedBy").Order("release_date DESC, created_at DESC").Find(&entries).Error; err != nil {
log.Printf("Error loading changelog entries: %v", err)
}
util.RenderHTMLOK(c, "admin-changelog.html", gin.H{
"lang": langStr,
"user": user,
"entries": entries,
"title": "Changelog Management",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// AdminChangelogCreate shows the form for creating a new changelog entry
func AdminChangelogCreate(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "New Changelog Entry",
"isEdit": false,
})
}
// AdminChangelogSave handles the creation of a new changelog entry
func AdminChangelogSave(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
version := c.PostForm("version")
releaseDateStr := c.PostForm("release_date")
title := c.PostForm("title")
changes := c.PostForm("changes")
// Validate required fields
if version == "" || releaseDateStr == "" || changes == "" {
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "New Changelog Entry",
"isEdit": false,
"error": "validation_failed",
"version": version,
"releaseDate": releaseDateStr,
"entryTitle": title,
"changes": changes,
})
return
}
// Parse release date
releaseDate, err := time.Parse("2006-01-02", releaseDateStr)
if err != nil {
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "New Changelog Entry",
"isEdit": false,
"error": "invalid_date",
"version": version,
"releaseDate": releaseDateStr,
"entryTitle": title,
"changes": changes,
})
return
}
entry := model.ChangelogEntry{
Version: version,
ReleaseDate: releaseDate,
Title: title,
Changes: changes,
CreatedById: user.ID,
}
if err := model.DB.Create(&entry).Error; err != nil {
log.Printf("Error creating changelog entry: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=create_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?success=created")
}
// AdminChangelogEdit shows the form for editing an existing changelog entry
func AdminChangelogEdit(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=invalid_id")
return
}
var entry model.ChangelogEntry
if err := model.DB.First(&entry, id).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=not_found")
return
}
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "Edit Changelog Entry",
"isEdit": true,
"entry": entry,
"version": entry.Version,
"releaseDate": entry.ReleaseDate.Format("2006-01-02"),
"entryTitle": entry.Title,
"changes": entry.Changes,
})
}
// AdminChangelogUpdate handles the update of an existing changelog entry
func AdminChangelogUpdate(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=invalid_id")
return
}
var entry model.ChangelogEntry
if err := model.DB.First(&entry, id).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=not_found")
return
}
version := c.PostForm("version")
releaseDateStr := c.PostForm("release_date")
title := c.PostForm("title")
changes := c.PostForm("changes")
// Validate required fields
if version == "" || releaseDateStr == "" || changes == "" {
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "Edit Changelog Entry",
"isEdit": true,
"entry": entry,
"error": "validation_failed",
"version": version,
"releaseDate": releaseDateStr,
"entryTitle": title,
"changes": changes,
})
return
}
// Parse release date
releaseDate, err := time.Parse("2006-01-02", releaseDateStr)
if err != nil {
util.RenderHTMLOK(c, "admin-changelog-form.html", gin.H{
"lang": langStr,
"user": user,
"title": "Edit Changelog Entry",
"isEdit": true,
"entry": entry,
"error": "invalid_date",
"version": version,
"releaseDate": releaseDateStr,
"entryTitle": title,
"changes": changes,
})
return
}
entry.Version = version
entry.ReleaseDate = releaseDate
entry.Title = title
entry.Changes = changes
if err := model.DB.Save(&entry).Error; err != nil {
log.Printf("Error updating changelog entry: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=update_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?success=updated")
}
// AdminChangelogDelete handles the deletion of a changelog entry
func AdminChangelogDelete(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Admin only
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=invalid_id")
return
}
if err := model.DB.Delete(&model.ChangelogEntry{}, id).Error; err != nil {
log.Printf("Error deleting changelog entry: %v", err)
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?error=delete_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/admin/changelog?success=deleted")
}
package controller
// Child controller - handles child detail views for parents and employees
// Implements dual HTML/JSON API pattern for web and mobile clients
//
// [impl->dsn~dual-api-architektur~1]
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
)
// shouldReturnJSON determines if the response should be JSON instead of HTML
// Supports dual-frontend architecture: HTML for web, JSON for mobile/SPA
//
// [impl->dsn~dual-api-architektur~1]
func shouldReturnJSON(c *gin.Context) bool {
// Check if route starts with /api/
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
return true
}
// Check Accept header
if c.GetHeader("Accept") == "application/json" {
return true
}
return false
}
// getUserOrDummy returns the authenticated user or a dummy user if not authenticated
func getUserOrDummy(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
func Child(c *gin.Context) {
isJSON := shouldReturnJSON(c)
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummy(c),
})
}
return
}
user := userInterface.(*model.User)
id := c.Param("id")
// Parse and validate child ID
childID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid child ID format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "404.html", gin.H{
"title": "Wippidu - invalid child ID",
"lang": langStr,
"user": user,
})
}
return
}
// SECURITY FIX: Authorization check
// Verify user has permission to access this child
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"title": "Wippidu - server error",
"lang": langStr,
"user": user,
})
}
return
}
if !canAccess {
// Return 403 Forbidden for authorization failure
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "You do not have permission to view this child's information",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "You do not have permission to view this child's information.",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch child data (now authorized)
child := model.Child{}
model.DB.Where("id = ?", childID).First(&child)
// Check if child exists
if child.ID == 0 {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Child not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - child not found",
"lang": langStr,
"user": user,
})
}
return
}
model.DB.Preload(clause.Associations).Preload("Group.Location").Find(&child)
// Return JSON or HTML
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"child": child,
},
})
} else {
data, _ := json.MarshalIndent(child, "", " ")
util.RenderHTMLOK(c, "child.html", gin.H{
"title": fmt.Sprintf("Your child '%s'", child.FirstName),
"user": user,
"child": child,
"content": string(data),
"lang": langStr,
})
}
}
package controller
// Child cluster controller - handles management of named child subsets within groups
// Group leaders and location leaders can create clusters for quick message recipient selection
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ListChildClusters shows all clusters for the user's accessible groups
func ListChildClusters(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLeaders and LocationLeaders can manage clusters
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get groups user can manage
groups, err := service.GetGroupsForClusterManagement(model.DB, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get group IDs
var groupIDs []uint
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
// Get clusters for those groups
clusters, err := service.GetClustersForGroups(model.DB, groupIDs)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "cluster-list.html", gin.H{
"lang": langStr,
"user": user,
"clusters": clusters,
"groups": groups,
"title": "Child Clusters",
})
}
// ShowCreateClusterForm displays the form to create a new cluster
func ShowCreateClusterForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLeaders and LocationLeaders can create clusters
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get groups user can manage
groups, err := service.GetGroupsForClusterManagement(model.DB, user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// If a group_id is provided, pre-select it and load children
var selectedGroupID uint
var children []model.Child
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
if gid, err := strconv.ParseUint(groupIDStr, 10, 32); err == nil {
selectedGroupID = uint(gid)
// Verify user can manage this group
if service.CanUserManageCluster(model.DB, user, selectedGroupID) {
model.DB.Where("group_id = ?", selectedGroupID).Order("last_name, first_name").Find(&children)
}
}
}
util.RenderHTMLOK(c, "cluster-create.html", gin.H{
"lang": langStr,
"user": user,
"groups": groups,
"children": children,
"selectedGroupID": selectedGroupID,
"title": "Create Child Cluster",
})
}
// CreateChildCluster handles the creation of a new cluster
func CreateChildCluster(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLeaders and LocationLeaders can create clusters
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
name := c.PostForm("name")
groupIDStr := c.PostForm("group_id")
childIDStrs := c.PostFormArray("child_ids")
// Validate name
if name == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse group ID
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Check permission for this group
if !service.CanUserManageCluster(model.DB, user, uint(groupID)) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse child IDs
var childIDs []uint
for _, idStr := range childIDStrs {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
childIDs = append(childIDs, uint(id))
}
}
// Create the cluster
cluster := model.ChildCluster{
Name: name,
GroupId: uint(groupID),
CreatedById: user.ID,
}
if err := model.DB.Create(&cluster).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Add children to the cluster — filter to children actually in the
// cluster's group. Audit #464: the create form only ever displays
// the group's own children, but the submitted child_ids list is
// trusted from form input. A tampered POST could bind children from
// any other group (or location) to the cluster. Filtering by
// group_id closes that.
if len(childIDs) > 0 {
var children []model.Child
model.DB.
Where("id IN ? AND group_id = ?", childIDs, cluster.GroupId).
Find(&children)
model.DB.Model(&cluster).Association("Children").Append(&children)
}
c.Redirect(http.StatusFound, "/"+langStr+"/clusters")
}
// ShowEditClusterForm displays the form to edit an existing cluster
func ShowEditClusterForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get cluster ID from URL
clusterIDStr := c.Param("id")
clusterID, err := strconv.ParseUint(clusterIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load the cluster
var cluster model.ChildCluster
if err := model.DB.Preload("Children").Preload("Group").First(&cluster, clusterID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check permission
if !service.CanUserManageCluster(model.DB, user, cluster.GroupId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get all children in the group
var children []model.Child
model.DB.Where("group_id = ?", cluster.GroupId).Order("last_name, first_name").Find(&children)
// Mark which children are in the cluster
selectedChildIDs := make(map[uint]bool)
for _, child := range cluster.Children {
selectedChildIDs[child.ID] = true
}
util.RenderHTMLOK(c, "cluster-edit.html", gin.H{
"lang": langStr,
"user": user,
"cluster": cluster,
"children": children,
"selectedChildIDs": selectedChildIDs,
"title": "Edit Child Cluster",
})
}
// UpdateChildCluster handles updating an existing cluster
func UpdateChildCluster(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get cluster ID from URL
clusterIDStr := c.Param("id")
clusterID, err := strconv.ParseUint(clusterIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load the cluster
var cluster model.ChildCluster
if err := model.DB.First(&cluster, clusterID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check permission
if !service.CanUserManageCluster(model.DB, user, cluster.GroupId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data
name := c.PostForm("name")
childIDStrs := c.PostFormArray("child_ids")
// Validate name
if name == "" {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse child IDs
var childIDs []uint
for _, idStr := range childIDStrs {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
childIDs = append(childIDs, uint(id))
}
}
// Update the cluster
cluster.Name = name
if err := model.DB.Save(&cluster).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Update children - clear and re-add. Same group-id filter as
// CreateChildCluster (audit #464) to keep tampered POSTs from
// re-binding children from other groups/locations into the cluster.
model.DB.Model(&cluster).Association("Children").Clear()
if len(childIDs) > 0 {
var children []model.Child
model.DB.
Where("id IN ? AND group_id = ?", childIDs, cluster.GroupId).
Find(&children)
model.DB.Model(&cluster).Association("Children").Append(&children)
}
c.Redirect(http.StatusFound, "/"+langStr+"/clusters")
}
// DeleteChildCluster handles deleting a cluster
func DeleteChildCluster(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get cluster ID from URL
clusterIDStr := c.Param("id")
clusterID, err := strconv.ParseUint(clusterIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load the cluster
var cluster model.ChildCluster
if err := model.DB.First(&cluster, clusterID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check permission
if !service.CanUserManageCluster(model.DB, user, cluster.GroupId) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Clear associations first
model.DB.Model(&cluster).Association("Children").Clear()
// Delete the cluster (soft delete via gorm.Model)
if err := model.DB.Delete(&cluster).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/clusters")
}
// GetChildrenForGroup returns children for a group (AJAX endpoint for dynamic form updates)
func GetChildrenForGroup(c *gin.Context) {
userInterface, ok := c.Get("User")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
return
}
user := userInterface.(*model.User)
// Get group ID from query
groupIDStr := c.Query("group_id")
groupID, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid group_id"})
return
}
// Check permission
if !service.CanUserManageCluster(model.DB, user, uint(groupID)) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
// Get children for the group
var children []model.Child
model.DB.Where("group_id = ?", groupID).Order("last_name, first_name").Find(&children)
// Return simplified child data
type ChildData struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
var childData []ChildData
for _, child := range children {
childData = append(childData, ChildData{
ID: child.ID,
FirstName: child.FirstName,
LastName: child.LastName,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"children": childData,
})
}
package controller
import (
"encoding/json"
"fmt"
"net/http"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// DebugUserChildren is a diagnostic endpoint to check parent-child relationships
// Access via: /de/debug/user/{userId}/children
func DebugUserChildren(c *gin.Context) {
userID := c.Param("userId")
var user model.User
err := model.DB.Where("id = ?", userID).
Preload("Children.Group.Location").
Preload("Roles").
First(&user).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
"id": userID,
})
return
}
// Create detailed debug info
debug := gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"roles": func() []string {
roles := make([]string, len(user.Roles))
for i, r := range user.Roles {
roles[i] = r.Name
}
return roles
}(),
"isParent": user.IsParent(),
"isEmployee": user.IsEmployee(),
},
"children_count": len(user.Children),
"children": []gin.H{},
}
for _, child := range user.Children {
childInfo := gin.H{
"id": child.ID,
"first_name": child.FirstName,
"last_name": child.LastName,
"active": child.Active,
}
if child.Group != nil {
childInfo["group"] = gin.H{
"id": child.Group.ID,
"name": child.Group.Name,
}
if child.Group.Location != nil {
childInfo["location"] = gin.H{
"id": child.Group.Location.ID,
"name": child.Group.Location.Name,
}
}
}
debug["children"] = append(debug["children"].([]gin.H), childInfo)
}
// Pretty print JSON
jsonBytes, _ := json.MarshalIndent(debug, "", " ")
c.Data(http.StatusOK, "application/json", jsonBytes)
}
// DebugHome is a diagnostic endpoint that shows exactly what data the Home controller sees
// Access via: /de/debug/home
func DebugHome(c *gin.Context) {
id, exists := c.Get("userident")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
// Same logic as Home controller
currentUser := model.User{}
model.DB.Where("id = ?", id).Preload("Roles").First(¤tUser)
debug := gin.H{
"authenticated_user_id": id,
"user": gin.H{
"id": currentUser.ID,
"email": currentUser.Email,
"isParent": currentUser.IsParent(),
"isEmployee": currentUser.IsEmployee(),
},
}
if currentUser.IsEmployee() {
var groups []model.Group
model.DB.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", currentUser.ID).
Preload("Location").
Order("groups.id ASC").
Find(&groups)
debug["employee_groups"] = len(groups)
debug["employee_flow"] = true
if len(groups) > 0 {
primaryGroup := groups[0]
var children []model.Child
model.DB.Preload("Group.Location").
Where("group_id = ?", primaryGroup.ID).
Find(&children)
debug["primary_group"] = primaryGroup.Name
debug["children_in_group"] = len(children)
}
} else {
// Parent flow
parent := model.User{}
model.DB.Where("id = ?", id).
Preload("Children.Group.Location").
Preload("Roles").
First(&parent)
debug["parent_flow"] = true
debug["children_count"] = len(parent.Children)
children := []gin.H{}
for _, child := range parent.Children {
childInfo := gin.H{
"id": child.ID,
"first_name": child.FirstName,
"last_name": child.LastName,
}
if child.Group != nil {
childInfo["group"] = child.Group.Name
if child.Group.Location != nil {
childInfo["location"] = child.Group.Location.Name
}
}
children = append(children, childInfo)
}
debug["children"] = children
}
// Check what's in user_children table
var userChildRows []struct {
UserID uint
ChildID uint
}
model.DB.Table("user_children").Where("user_id = ?", id).Find(&userChildRows)
debug["user_children_table_rows"] = len(userChildRows)
jsonBytes, _ := json.MarshalIndent(debug, "", " ")
c.Data(http.StatusOK, "application/json", jsonBytes)
}
// DebugDatabaseState shows overall database state
// Access via: /de/debug/database
func DebugDatabaseState(c *gin.Context) {
var userCount, childCount, locationCount, groupCount int64
var userChildrenCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Child{}).Count(&childCount)
model.DB.Model(&model.Location{}).Count(&locationCount)
model.DB.Model(&model.Group{}).Count(&groupCount)
model.DB.Table("user_children").Count(&userChildrenCount)
// Get specific test user
var emmaUser model.User
emmaErr := model.DB.Where("email = ?", "emma.mueller@wippidu.app").
Preload("Children.Group.Location").
First(&emmaUser).Error
debug := gin.H{
"database_counts": gin.H{
"users": userCount,
"children": childCount,
"locations": locationCount,
"groups": groupCount,
"user_children_rows": userChildrenCount,
},
}
if emmaErr == nil {
debug["emma_mueller"] = gin.H{
"id": emmaUser.ID,
"email": emmaUser.Email,
"children_count": len(emmaUser.Children),
"children": func() []string {
names := make([]string, len(emmaUser.Children))
for i, c := range emmaUser.Children {
names[i] = fmt.Sprintf("%s %s", c.FirstName, c.LastName)
}
return names
}(),
}
} else {
debug["emma_mueller"] = gin.H{"error": "Not found"}
}
jsonBytes, _ := json.MarshalIndent(debug, "", " ")
c.Data(http.StatusOK, "application/json", jsonBytes)
}
package controller
// Employee controller - handles employee views of children, notifications, and groups
// Employees can view children in their assigned groups and see absence notifications
//
// [impl->dsn~abwesenheitsmeldungen-design~1]
// [impl->dsn~gui-gruppenuebersicht~1]
import (
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// getUserOrDummyEmployee returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyEmployee(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
// Notifications displays all absence notifications for children in employee's groups
func Notifications(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyEmployee(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is an employee
model.DB.Preload("Roles").Find(user)
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Employee access required",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "Employee access required",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch employee's groups (handles intranet-linked employees)
groups, _ := user.GetEmployeeGroups(model.DB)
// For location leads, also include all groups at their locations
// This must match the logic in GetUnacknowledgedAbsenceNoticesCount
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
var locationGroups []model.Group
model.DB.Where("location_id IN ?", locationIDs).Find(&locationGroups)
// Merge groups (avoiding duplicates)
groupIDSet := make(map[uint]bool)
for _, g := range groups {
groupIDSet[g.ID] = true
}
for _, g := range locationGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
}
}
if len(groups) == 0 {
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"notifications": []model.AbsenceNotification{},
"tab": "immediate",
"tabCounts": gin.H{
"immediate": 0,
"active": 0,
"future": 0,
"archive": 0,
},
},
})
} else {
util.RenderHTMLOK(c, "notifications.html", gin.H{
"title": "Child Notifications",
"user": user,
"notifications": []model.AbsenceNotification{},
"lang": langStr,
"activeTab": "immediate",
"timeframeWeeks": 1,
"tabCounts": gin.H{
"immediate": int64(0),
"active": int64(0),
"future": int64(0),
"archive": int64(0),
},
})
}
return
}
// Collect all group IDs
groupIDs := make([]uint, len(groups))
for i, group := range groups {
groupIDs[i] = group.ID
}
// Parse tab parameter (immediate, active, future, archive)
tab := c.DefaultQuery("tab", "active")
if tab != "immediate" && tab != "active" && tab != "future" && tab != "archive" {
tab = "active"
}
// Parse timeframe for immediate/future tabs (1, 2, or 3 weeks)
timeframeWeeks, _ := strconv.Atoi(c.DefaultQuery("weeks", "1"))
if timeframeWeeks < 1 || timeframeWeeks > 3 {
timeframeWeeks = 1
}
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
immediateCutoff := now.AddDate(0, 0, timeframeWeeks*7)
// Build base query
baseQuery := model.DB.Preload("Child").
Preload("Child.Group").
Preload("User").
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL")
// Fetch notifications based on active tab
var notifications []model.AbsenceNotification
switch tab {
case "immediate":
// Future absences starting within the timeframe
baseQuery.
Where("absence_notifications.from_date > ?", now).
Where("absence_notifications.from_date <= ?", immediateCutoff).
Order("absence_notifications.from_date ASC"). // Soonest first
Find(¬ifications)
case "active":
// Currently active absences
baseQuery.
Where("absence_notifications.from_date <= ?", now).
Where("absence_notifications.to_date >= ?", startOfToday).
Order("absence_notifications.from_date ASC").
Find(¬ifications)
case "future":
// Far future (beyond timeframe)
baseQuery.
Where("absence_notifications.from_date > ?", immediateCutoff).
Order("absence_notifications.from_date ASC"). // Soonest first
Find(¬ifications)
case "archive":
// Past absences
baseQuery.
Where("absence_notifications.to_date < ?", startOfToday).
Order("absence_notifications.to_date DESC"). // Most recent first
Find(¬ifications)
}
// Get counts for each tab (for badges)
var immediateCount, activeCount, futureCount, archiveCount int64
model.DB.Model(&model.AbsenceNotification{}).
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL").
Where("absence_notifications.from_date > ?", now).
Where("absence_notifications.from_date <= ?", immediateCutoff).
Count(&immediateCount)
model.DB.Model(&model.AbsenceNotification{}).
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL").
Where("absence_notifications.from_date <= ?", now).
Where("absence_notifications.to_date >= ?", startOfToday).
Count(&activeCount)
model.DB.Model(&model.AbsenceNotification{}).
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL").
Where("absence_notifications.from_date > ?", immediateCutoff).
Count(&futureCount)
model.DB.Model(&model.AbsenceNotification{}).
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL").
Where("absence_notifications.to_date < ?", startOfToday).
Count(&archiveCount)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"notifications": notifications,
"tab": tab,
"tabCounts": gin.H{
"immediate": immediateCount,
"active": activeCount,
"future": futureCount,
"archive": archiveCount,
},
},
})
} else {
util.RenderHTMLOK(c, "notifications.html", gin.H{
"title": "Child Notifications",
"user": user,
"notifications": notifications,
"now": now,
"lang": langStr,
"activeTab": tab,
"timeframeWeeks": timeframeWeeks,
"tabCounts": gin.H{
"immediate": immediateCount,
"active": activeCount,
"future": futureCount,
"archive": archiveCount,
},
})
}
}
// getEmployeeGroupIDs returns group IDs accessible by the given employee user.
// For location leads, includes all groups at their locations.
// Handles intranet-linked employees via GetEmployeeGroups.
func getEmployeeGroupIDs(user *model.User) []uint {
groups, _ := user.GetEmployeeGroups(model.DB)
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
var locationGroups []model.Group
model.DB.Where("location_id IN ?", locationIDs).Find(&locationGroups)
groupIDSet := make(map[uint]bool)
for _, g := range groups {
groupIDSet[g.ID] = true
}
for _, g := range locationGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
}
}
}
}
groupIDs := make([]uint, len(groups))
for i, g := range groups {
groupIDs[i] = g.ID
}
return groupIDs
}
// AcknowledgeNotification marks a single absence notification as read
func AcknowledgeNotification(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{"code": "UNAUTHORIZED", "message": "User not found in context"}})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{"title": "Wippidu - unauthorized", "lang": langStr, "user": getUserOrDummyEmployee(c)})
}
return
}
user := userInterface.(*model.User)
model.DB.Preload("Roles").Find(user)
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": gin.H{"code": "FORBIDDEN", "message": "Employee access required"}})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"title": "Wippidu - access denied", "message": "Employee access required", "lang": langStr, "user": user})
}
return
}
notificationID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "Invalid notification ID"}})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/notifications")
}
return
}
// Verify notification exists
var notification model.AbsenceNotification
if err := model.DB.Preload("Child").First(¬ification, notificationID).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": gin.H{"code": "NOT_FOUND", "message": "Notification not found"}})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/notifications")
}
return
}
// Verify the notification belongs to a child in the employee's groups
groupIDs := getEmployeeGroupIDs(user)
if notification.Child.GroupId == nil || !containsUint(groupIDs, *notification.Child.GroupId) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": gin.H{"code": "FORBIDDEN", "message": "Notification not in your groups"}})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/notifications")
}
return
}
// Mark as acknowledged
now := time.Now()
model.DB.Model(¬ification).Updates(map[string]interface{}{
"acknowledged": true,
"acknowledged_by": user.ID,
"acknowledged_at": now,
})
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
// Redirect back to the same tab/weeks
tab := c.PostForm("tab")
weeks := c.PostForm("weeks")
redirectURL := "/" + langStr + "/notifications"
if tab != "" {
redirectURL += "?tab=" + tab
if weeks != "" {
redirectURL += "&weeks=" + weeks
}
}
c.Redirect(http.StatusFound, redirectURL)
}
}
// AcknowledgeAllNotifications marks all notifications in the current tab as read
func AcknowledgeAllNotifications(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{"code": "UNAUTHORIZED", "message": "User not found in context"}})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{"title": "Wippidu - unauthorized", "lang": langStr, "user": getUserOrDummyEmployee(c)})
}
return
}
user := userInterface.(*model.User)
model.DB.Preload("Roles").Find(user)
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": gin.H{"code": "FORBIDDEN", "message": "Employee access required"}})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"title": "Wippidu - access denied", "message": "Employee access required", "lang": langStr, "user": user})
}
return
}
groupIDs := getEmployeeGroupIDs(user)
if len(groupIDs) == 0 {
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/notifications")
}
return
}
tab := c.PostForm("tab")
if tab == "" {
tab = "immediate"
}
timeframeWeeks, _ := strconv.Atoi(c.PostForm("weeks"))
if timeframeWeeks < 1 || timeframeWeeks > 3 {
timeframeWeeks = 1
}
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
immediateCutoff := now.AddDate(0, 0, timeframeWeeks*7)
// First find matching notification IDs, then update them
// (JOIN in UPDATE not supported by all databases)
selectQuery := model.DB.Model(&model.AbsenceNotification{}).
Select("absence_notifications.id").
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN (?)", groupIDs).
Where("absence_notifications.deleted_at IS NULL").
Where("absence_notifications.acknowledged = ?", false)
switch tab {
case "immediate":
selectQuery = selectQuery.
Where("absence_notifications.from_date > ?", now).
Where("absence_notifications.from_date <= ?", immediateCutoff)
case "active":
selectQuery = selectQuery.
Where("absence_notifications.from_date <= ?", now).
Where("absence_notifications.to_date >= ?", startOfToday)
case "future":
selectQuery = selectQuery.
Where("absence_notifications.from_date > ?", immediateCutoff)
case "archive":
selectQuery = selectQuery.
Where("absence_notifications.to_date < ?", startOfToday)
}
var notificationIDs []uint
selectQuery.Pluck("absence_notifications.id", ¬ificationIDs)
if len(notificationIDs) > 0 {
model.DB.Model(&model.AbsenceNotification{}).
Where("id IN ?", notificationIDs).
Updates(map[string]interface{}{
"acknowledged": true,
"acknowledged_by": user.ID,
"acknowledged_at": now,
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
redirectURL := "/" + langStr + "/notifications?tab=" + tab
if tab == "immediate" || tab == "future" {
redirectURL += "&weeks=" + strconv.Itoa(timeframeWeeks)
}
c.Redirect(http.StatusFound, redirectURL)
}
}
// containsUint checks if a uint value exists in a slice
func containsUint(slice []uint, val uint) bool {
for _, item := range slice {
if item == val {
return true
}
}
return false
}
// EmployeeChildDetail displays detailed information about a child for employees
func EmployeeChildDetail(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyEmployee(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Check if user is an employee OR admin with location selected
model.DB.Preload("Roles").Find(user)
if !user.IsEmployee() && !isAdminWithLocation {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Employee access required",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "Employee access required",
"lang": langStr,
"user": user,
})
}
return
}
id := c.Param("id")
// Parse child ID
childID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid child ID format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "404.html", gin.H{
"title": "Wippidu - invalid child ID",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the child
var child model.Child
err = model.DB.Preload("Group.Location").
Preload("Users.Roles").
Where("id = ?", childID).
First(&child).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Child not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - child not found",
"lang": langStr,
"user": user,
})
}
return
}
// Verify user has access to this child
var hasAccess bool
if isAdminWithLocation {
// Admin with selected location can view children in that location
var locationCount int64
model.DB.Table("children").
Joins("JOIN groups ON groups.id = children.group_id").
Where("children.id = ? AND groups.location_id = ?", childID, adminLocId).
Count(&locationCount)
hasAccess = locationCount > 0
} else {
// Get the employee's groups (handles intranet-linked employees)
employeeGroups, _ := user.GetEmployeeGroups(model.DB)
// First check if child is in the employee's assigned groups
for _, eg := range employeeGroups {
if child.GroupId != nil && eg.ID == *child.GroupId {
hasAccess = true
break
}
}
// If not in direct group, check if employee can access all children at their location
if !hasAccess && child.Group != nil && child.Group.Location != nil {
// Check if child's location matches employee's location and employee has access
for _, eg := range employeeGroups {
if eg.Location != nil && eg.LocationId == child.Group.LocationId {
if eg.Location.CanEmployeeAccessAllChildren(user) {
hasAccess = true
break
}
}
}
}
}
if !hasAccess {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You can only view children in your assigned groups",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "You can only view children in your assigned groups",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch all absence notifications for this child (not deleted)
var notifications []model.AbsenceNotification
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
model.DB.Preload("User").
Where("child_id = ?", childID).
Order("from_date DESC").
Find(¬ifications)
// Fetch last 3 messages for this child
var messages []model.Message
model.DB.Preload("CreatedBy").
Preload("Recipients").
Where("child_id = ?", childID).
Order("created_at DESC").
Limit(3).
Find(&messages)
// For each message, count read receipts and fetch answers
type ParentAnswer struct {
ParentName string
ParentRole string
Answer string
AnsweredAt time.Time
}
type MessageWithReadCount struct {
Message model.Message
ReadCount int
TotalRecipients int
AnswerCount int
Answers []ParentAnswer
}
var messagesWithCounts []MessageWithReadCount
for _, msg := range messages {
var readCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND read_at IS NOT NULL", msg.ID).
Count(&readCount)
var answerCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND answer IS NOT NULL", msg.ID).
Count(&answerCount)
// Fetch answers with parent details for answer_possible and answer_required messages
var answers []ParentAnswer
if msg.InteractionType == "answer_possible" || msg.InteractionType == "answer_required" {
var messageReads []model.MessageRead
model.DB.Preload("User.Roles").
Where("message_id = ? AND answer IS NOT NULL", msg.ID).
Order("answered_at ASC").
Find(&messageReads)
for _, mr := range messageReads {
if mr.Answer != nil && mr.AnsweredAt != nil {
// Get the first role name, or "Unknown" if no roles
roleName := "Unknown"
if len(mr.User.Roles) > 0 {
roleName = mr.User.Roles[0].Name
}
answers = append(answers, ParentAnswer{
ParentName: mr.User.Email,
ParentRole: roleName,
Answer: *mr.Answer,
AnsweredAt: *mr.AnsweredAt,
})
}
}
}
messagesWithCounts = append(messagesWithCounts, MessageWithReadCount{
Message: msg,
ReadCount: int(readCount),
TotalRecipients: len(msg.Recipients),
AnswerCount: int(answerCount),
Answers: answers,
})
}
// Categorize notifications
type CategorizedNotifications struct {
Active []model.AbsenceNotification
Upcoming []model.AbsenceNotification
Past []model.AbsenceNotification
}
categorized := CategorizedNotifications{
Active: []model.AbsenceNotification{},
Upcoming: []model.AbsenceNotification{},
Past: []model.AbsenceNotification{},
}
for _, notif := range notifications {
if notif.FromDate.After(now) {
categorized.Upcoming = append(categorized.Upcoming, notif)
} else if notif.ToDate.Before(startOfToday) {
categorized.Past = append(categorized.Past, notif)
} else {
categorized.Active = append(categorized.Active, notif)
}
}
// Check if child has any registered parents
hasParents := len(child.Users) > 0
// Render employee notes (markdown)
var notesHTML template.HTML
if child.EmployeeNotes != "" {
notesHTML = util.RenderMarkdown(child.EmployeeNotes)
}
canEditNotes := user.IsGroupLeader() || user.IsHouseLeader() || user.IsAdmin()
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"child": child,
"notifications": categorized,
"messages": messagesWithCounts,
"hasParents": hasParents,
"notesHTML": notesHTML,
"canEditNotes": canEditNotes,
},
})
} else {
util.RenderHTMLOK(c, "employee-child.html", gin.H{
"title": "Child Information",
"user": user,
"child": child,
"notifications": categorized,
"messages": messagesWithCounts,
"lang": langStr,
"hasParents": hasParents,
"notesHTML": notesHTML,
"notesRaw": child.EmployeeNotes,
"canEditNotes": canEditNotes,
})
}
}
// SaveChildNotes handles POST to save employee notes for a child (GroupLead/LocationLead/Admin only)
func SaveChildNotes(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil || !canAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
notes := strings.TrimSpace(c.PostForm("employee_notes"))
model.DB.Model(&model.Child{}).Where("id = ?", childID).Update("employee_notes", notes)
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+c.Param("id"))
}
// LocationChildren displays all children in the employee's location
func LocationChildren(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyEmployee(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Check if user is an employee OR admin with location selected
model.DB.Preload("Roles").Find(user)
if !user.IsEmployee() && !isAdminWithLocation {
if user.IsAdmin() {
// Admin without location selected - show message to select a location
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "children",
})
return
}
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Employee access required",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "Employee access required",
"lang": langStr,
"user": user,
})
}
return
}
// Get the location - either from admin selection or from employee's group
var location *model.Location
if isAdminWithLocation {
// Admin with selected location
var loc model.Location
if err := model.DB.First(&loc, adminLocId).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Location not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - location not found",
"lang": langStr,
"user": user,
})
}
return
}
location = &loc
} else {
// Employee - get location from their first group (handles intranet-linked employees)
groups, _ := user.GetEmployeeGroups(model.DB)
if len(groups) == 0 {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "No group assigned to employee",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - no group assigned",
"lang": langStr,
"user": user,
})
}
return
}
location = groups[0].Location
}
// Access check: Check if user can access all children based on their role and location settings
// Admin always has access
if !user.IsAdmin() {
// Check location access based on user role and location settings
if location == nil || !location.CanEmployeeAccessAllChildren(user) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Access to all location children is not enabled for this location",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "Access to all location children is not enabled for this location",
"lang": langStr,
"user": user,
})
}
return
}
}
// Calculate view dates based on current day of week
now := time.Now()
todayTab, tomorrowTab := getEmployeeViewDates(now)
// Get the view parameter (default to "today")
viewParam := c.DefaultQuery("view", "today")
if viewParam != "today" && viewParam != "tomorrow" {
viewParam = "today"
}
// Determine which date to query based on view parameter
var viewDate time.Time
if viewParam == "tomorrow" {
viewDate = tomorrowTab.Date
} else {
viewDate = todayTab.Date
}
// Calculate date range for absence query
startOfViewDate := time.Date(viewDate.Year(), viewDate.Month(), viewDate.Day(), 0, 0, 0, 0, viewDate.Location())
endOfViewDate := startOfViewDate.AddDate(0, 0, 1)
// Fetch all children at this location with active absence notifications for the view date
// Filter by validity dates: only show children whose enrollment period includes today
var children []model.Child
model.DB.Preload("Group").
Preload("Group.Location").
Preload("AbsenceNotifications", "from_date < ? AND to_date >= ? AND deleted_at IS NULL", endOfViewDate, startOfViewDate).
Where("group_id IN (?)", model.DB.Table("groups").Select("id").Where("location_id = ?", location.ID)).
Where("(valid_from IS NULL OR valid_from <= ?)", now).
Where("(valid_until IS NULL OR valid_until >= ?)", now).
Order("first_name ASC").
Find(&children)
// Create child data with absence and booking status
type ChildWithAbsence struct {
Child model.Child
HasActiveAbsence bool
ActiveNotification *model.AbsenceNotification
IsNotBooked bool
}
// Group children by their group
type GroupWithChildren struct {
Group model.Group
Children []ChildWithAbsence
}
// Batch-check booking status for all children on the view date
childIDs := make([]uint, len(children))
for i, child := range children {
childIDs[i] = child.ID
}
bookingStatus := service.GetChildrenBookingStatus(model.DB, childIDs, viewDate)
// Build a map of group ID to group and children
groupMap := make(map[uint]*GroupWithChildren)
for _, child := range children {
if child.GroupId == nil || child.Group == nil {
continue
}
groupID := *child.GroupId
// Create group entry if it doesn't exist
if _, exists := groupMap[groupID]; !exists {
groupMap[groupID] = &GroupWithChildren{
Group: *child.Group,
Children: make([]ChildWithAbsence, 0),
}
}
// Add child to group
hasAbsence := len(child.AbsenceNotifications) > 0
var activeNotif *model.AbsenceNotification
if hasAbsence {
activeNotif = &child.AbsenceNotifications[0]
}
groupMap[groupID].Children = append(groupMap[groupID].Children, ChildWithAbsence{
Child: child,
HasActiveAbsence: hasAbsence,
ActiveNotification: activeNotif,
IsNotBooked: !bookingStatus[child.ID],
})
}
// Convert map to sorted slice
groupedChildren := make([]GroupWithChildren, 0, len(groupMap))
for _, groupData := range groupMap {
groupedChildren = append(groupedChildren, *groupData)
}
// Sort groups by name
sort.Slice(groupedChildren, func(i, j int) bool {
return groupedChildren[i].Group.Name < groupedChildren[j].Group.Name
})
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"location": location,
"groups": groupedChildren,
"selectedView": viewParam,
"todayTab": todayTab,
"tomorrowTab": tomorrowTab,
},
})
} else {
util.RenderHTMLOK(c, "location-children.html", gin.H{
"title": "All Location Children",
"user": user,
"location": location,
"groups": groupedChildren,
"lang": langStr,
"selectedView": viewParam,
"todayTab": todayTab,
"tomorrowTab": tomorrowTab,
})
}
}
package controller
// Employee Chat controller - handles internal employee messaging
// Employees can chat with co-workers at their location
// Location leads can create custom chats
import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// EmployeeChatList shows the main chat view with chat tabs and messages
func EmployeeChatList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Only employees can access chat
if !user.IsEmployee() && !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get admin location ID if admin
var adminLocationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
adminLocationID = uint(id)
}
}
}
// Admin must select a location
if user.IsAdmin() && adminLocationID == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "select a location"})
} else {
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "chat",
})
}
return
}
// Get employee's location for ensuring default chat exists
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
if user.IsAdmin() && adminLocationID > 0 {
locationIDs = []uint{adminLocationID}
} else {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "no location assigned"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Ensure default chat exists for user's locations and sync participant
for _, locID := range locationIDs {
chat, err := service.GetOrCreateDefaultChat(model.DB, locID)
if err != nil {
continue
}
if chat.IsDefault {
service.EnsureUserInChat(model.DB, chat.ID, user.ID)
}
}
// Get chats with unread counts
chatsWithUnread, err := service.GetChatsWithUnreadCounts(model.DB, user, adminLocationID)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get chats"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get first chat's messages (or selected chat)
var activeChatID uint
var messages []model.ChatMessage
chatIDStr := c.Query("chat")
if chatIDStr != "" {
if id, err := strconv.ParseUint(chatIDStr, 10, 32); err == nil {
activeChatID = uint(id)
}
}
if activeChatID == 0 && len(chatsWithUnread) > 0 {
activeChatID = chatsWithUnread[0].Chat.ID
}
// Message with rendered HTML for templates
type messageWithHTML struct {
model.ChatMessage
HTMLContent template.HTML
}
var messagesWithHTML []messageWithHTML
if activeChatID > 0 {
// Verify access
if !service.CanUserAccessChat(model.DB, user, activeChatID, adminLocationID) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "access denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get messages (reversed to show oldest first in UI)
messages, _ = service.GetChatMessages(model.DB, activeChatID, 10, 0)
// Reverse for display (oldest first)
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
// Render markdown for each message
messagesWithHTML = make([]messageWithHTML, len(messages))
for i := range messages {
messagesWithHTML[i] = messageWithHTML{
ChatMessage: messages[i],
HTMLContent: util.RenderMarkdown(messages[i].Text),
}
}
// Mark messages as read
service.MarkChatMessagesAsRead(model.DB, activeChatID, user.ID)
}
// Check if user can create chats (LocationLead or Admin)
canCreateChat := user.IsHouseLeader() || user.IsAdmin()
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"chats": chatsWithUnread,
"activeChatId": activeChatID,
"messages": messagesWithHTML,
"canCreateChat": canCreateChat,
})
} else {
util.RenderHTMLOK(c, "employee-chat.html", gin.H{
"lang": langStr,
"user": user,
"title": "Chat",
"chats": chatsWithUnread,
"activeChatId": activeChatID,
"messages": messagesWithHTML,
"canCreateChat": canCreateChat,
})
}
}
// EmployeeChatView shows a specific chat (AJAX partial or redirect)
func EmployeeChatView(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
chatIDStr := c.Param("id")
chatID, err := strconv.ParseUint(chatIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid chat ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get admin location ID if admin
var adminLocationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
adminLocationID = uint(id)
}
}
}
// Check access
if !service.CanUserAccessChat(model.DB, user, uint(chatID), adminLocationID) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "access denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get messages
messages, err := service.GetChatMessages(model.DB, uint(chatID), 10, 0)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get messages"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Reverse for display (oldest first)
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
// Mark messages as read
service.MarkChatMessagesAsRead(model.DB, uint(chatID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"messages": messages,
})
} else {
// Redirect to main chat view with chat selected
c.Redirect(http.StatusFound, "/"+langStr+"/chat?chat="+chatIDStr)
}
}
// EmployeeChatMessages returns paginated messages for AJAX loading
func EmployeeChatMessages(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
return
}
user := userInterface.(*model.User)
chatIDStr := c.Param("id")
chatID, err := strconv.ParseUint(chatIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid chat ID"})
return
}
// Get admin location ID if admin
var adminLocationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
adminLocationID = uint(id)
}
}
}
// Check access
if !service.CanUserAccessChat(model.DB, user, uint(chatID), adminLocationID) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "access denied"})
return
}
// Get before ID for pagination
var beforeID uint
if beforeStr := c.Query("before"); beforeStr != "" {
if id, err := strconv.ParseUint(beforeStr, 10, 32); err == nil {
beforeID = uint(id)
}
}
// Get limit
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
limit = l
}
}
// Get messages
messages, err := service.GetChatMessages(model.DB, uint(chatID), limit, beforeID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get messages"})
return
}
// Reverse for display (oldest first)
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
// Check if there are more messages
hasMore := len(messages) == limit
// Build response with admin flags and rendered HTML
type messageWithAdmin struct {
*model.ChatMessage
SenderIsAdmin bool `json:"senderIsAdmin"`
HTMLContent string `json:"htmlContent"`
}
messagesWithAdmin := make([]messageWithAdmin, len(messages))
for i := range messages {
messagesWithAdmin[i] = messageWithAdmin{
ChatMessage: &messages[i],
SenderIsAdmin: messages[i].Sender.IsAdmin(),
HTMLContent: string(util.RenderMarkdown(messages[i].Text)),
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"messages": messagesWithAdmin,
"hasMore": hasMore,
})
}
// EmployeeChatSend sends a new message to a chat
func EmployeeChatSend(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
chatIDStr := c.Param("id")
chatID, err := strconv.ParseUint(chatIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid chat ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get admin location ID if admin
var adminLocationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
adminLocationID = uint(id)
}
}
}
// Check access
if !service.CanUserAccessChat(model.DB, user, uint(chatID), adminLocationID) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "access denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get message text
text := strings.TrimSpace(c.PostForm("text"))
if text == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "message cannot be empty"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/chat?chat="+chatIDStr+"&error=empty_message")
}
return
}
// Create message
message, err := service.SendChatMessage(model.DB, uint(chatID), user.ID, text)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to send message"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/chat?chat="+chatIDStr+"&error=send_failed")
}
return
}
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": message,
"senderIsAdmin": message.Sender.IsAdmin(),
"htmlContent": string(util.RenderMarkdown(message.Text)),
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/chat?chat="+chatIDStr)
}
}
// EmployeeChatCreateForm shows the form to create a new chat
func EmployeeChatCreateForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only location leads and admins can create chats
if !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID
var locationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
locationID = uint(id)
}
}
}
if locationID == 0 {
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
locationID = locationIDs[0]
}
// Get employees at location
employees, err := service.GetEmployeesForLocation(model.DB, locationID)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Get groups for quick select
groups, err := service.GetGroupsForLocation(model.DB, locationID)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Build employee-to-groups mapping from the loaded groups data.
// The User model has no Groups field, so we build this map for the template
// to support the group quick-select feature.
employeeGroups := make(map[uint]string)
for _, group := range groups {
for _, teacher := range group.Teachers {
employeeGroups[teacher.ID] += fmt.Sprintf("%d,", group.ID)
}
}
util.RenderHTMLOK(c, "employee-chat-create.html", gin.H{
"lang": langStr,
"user": user,
"title": "New Chat",
"employees": employees,
"groups": groups,
"employeeGroups": employeeGroups,
})
}
// EmployeeChatCreate handles the creation of a new chat
func EmployeeChatCreate(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Only location leads and admins can create chats
if !user.IsHouseLeader() && !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get form data
name := strings.TrimSpace(c.PostForm("name"))
participantIDsStr := c.PostFormArray("participants")
if name == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "name required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/chat/new?error=name_required")
}
return
}
// Parse participant IDs
var participantIDs []uint
for _, idStr := range participantIDsStr {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
participantIDs = append(participantIDs, uint(id))
}
}
// Get location ID
var locationID uint
if user.IsAdmin() {
if adminLocId, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocId.(int); ok && id > 0 {
locationID = uint(id)
}
}
}
if locationID == 0 {
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "no location"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
locationID = locationIDs[0]
}
// Create chat
chat, err := service.CreateChat(model.DB, name, locationID, user.ID, participantIDs)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create chat"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"chat": chat,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/chat?chat="+strconv.FormatUint(uint64(chat.ID), 10))
}
}
package controller
import (
"net/http"
"os"
"strconv"
"strings"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ShowBulkEmployeeInvitationPage displays the page for generating bulk employee invitation codes
func ShowBulkEmployeeInvitationPage(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only admins can access this page
if !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/home")
return
}
employeeInvService := service.NewEmployeeInvitationService(model.DB)
employees, err := employeeInvService.GetUnregisteredEmployees()
if err != nil {
util.RenderHTMLOK(c, "employee-invitation-bulk-select.html", gin.H{
"title": "employee_invitation.bulk.title",
"lang": langStr,
"user": user,
"error": "no_employees",
})
return
}
// Get active invitation IDs to enable "view existing codes" link
activeInvitations, _ := employeeInvService.GetActiveEmployeeInvitations()
var activeInvitationIDs []string
for _, inv := range activeInvitations {
activeInvitationIDs = append(activeInvitationIDs, strconv.FormatUint(uint64(inv.ID), 10))
}
activeInvitationIDsStr := strings.Join(activeInvitationIDs, ",")
util.RenderHTMLOK(c, "employee-invitation-bulk-select.html", gin.H{
"title": "employee_invitation.bulk.title",
"lang": langStr,
"user": user,
"employees": employees,
"activeInvitationIDs": activeInvitationIDsStr,
"activeInvitationCount": len(activeInvitations),
})
}
// GenerateBulkEmployeeInvitations generates invitation codes for selected employees
func GenerateBulkEmployeeInvitations(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only admins can generate codes
if !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/home")
return
}
// Parse selected employee IDs
employeeIDStrings := c.PostFormArray("employee_ids")
if len(employeeIDStrings) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=no_employees")
return
}
var employeeIDs []uint
for _, idStr := range employeeIDStrings {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
continue
}
employeeIDs = append(employeeIDs, uint(id))
}
if len(employeeIDs) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=no_valid_employees")
return
}
employeeInvService := service.NewEmployeeInvitationService(model.DB)
invitations, err := employeeInvService.GenerateBulkEmployeeInvitations(employeeIDs, user.ID)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=generation_failed")
return
}
if len(invitations) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=no_valid_employees")
return
}
// Collect invitation IDs for the print page
var invitationIDs []string
for _, inv := range invitations {
invitationIDs = append(invitationIDs, strconv.FormatUint(uint64(inv.ID), 10))
}
// Redirect to print page with the generated invitation IDs
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations/print?ids="+strings.Join(invitationIDs, ","))
}
// ShowBulkEmployeePrintPage displays the print page for employee invitation QR codes
func ShowBulkEmployeePrintPage(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only admins can access this page
if !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/home")
return
}
// Parse invitation IDs from query
idsParam := c.Query("ids")
if idsParam == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=codes_not_found")
return
}
idStrings := strings.Split(idsParam, ",")
var invitationIDs []uint
for _, idStr := range idStrings {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
continue
}
invitationIDs = append(invitationIDs, uint(id))
}
if len(invitationIDs) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=codes_not_found")
return
}
employeeInvService := service.NewEmployeeInvitationService(model.DB)
invitations, err := employeeInvService.GetEmployeeInvitationsByIDs(invitationIDs)
if err != nil || len(invitations) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/admin/employee-invitations?error=codes_not_found")
return
}
// Build base URL for QR codes (use same logic as parent invitations)
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
baseURL = scheme + "://" + baseURL
util.RenderHTMLOK(c, "employee-invitation-bulk-print.html", gin.H{
"title": "employee_invitation.bulk.print.title",
"lang": langStr,
"user": user,
"invitations": invitations,
"baseURL": baseURL,
})
}
// GetEmployeeInvitationInfo returns JSON info about an employee invitation code (public API)
func GetEmployeeInvitationInfo(c *gin.Context) {
code := c.Param("code")
employeeInvService := service.NewEmployeeInvitationService(model.DB)
invitation, err := employeeInvService.GetEmployeeInvitationByCode(code)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
return
}
if !invitation.IsValid() {
status := "invalid"
if invitation.IsUsed() {
status = "used"
} else if invitation.IsRevoked() {
status = "revoked"
} else if invitation.IsExpired() {
status = "expired"
}
c.JSON(http.StatusGone, gin.H{"error": status})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"employee_name": invitation.SyncEmployee.Vorname + " " + invitation.SyncEmployee.Nachname,
"expires_at": invitation.ExpiresAt,
})
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ShowFAQ displays the FAQ page with role-appropriate content
// - Parents see the parent FAQ
// - Employees/Admin/GroupLead/LocationLead see the employee FAQ
// - Dual-role users viewing as Employee see employee FAQ
// - Unauthenticated users are redirected to login
func ShowFAQ(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/login")
return
}
user, ok := userInterface.(*model.User)
if !ok || user == nil || user.ID == 0 {
c.Redirect(http.StatusFound, "/login")
return
}
// Get active role from session (for dual-role users)
activeRoleInterface, _ := c.Get("activeRole")
activeRole, _ := activeRoleInterface.(string)
settings, err := model.GetFAQSettings()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
})
return
}
// Determine which FAQ content to show based on role
// Employee FAQ is shown to: Employee, GroupLead, LocationLead, Admin
// Parent FAQ is shown to: Parent (unless viewing as Employee in dual-role)
var content string
var faqType string
isEmployeeView := activeRole == "Employee" ||
user.IsAdmin() ||
user.IsGroupLeader() ||
user.IsHouseLeader() ||
(user.IsEmployee() && !user.IsParent())
if settings != nil {
if isEmployeeView {
faqType = "employee"
if langStr == "en" {
content = settings.FAQEmployeeEN
} else {
content = settings.FAQEmployeeDE
}
} else {
faqType = "parent"
if langStr == "en" {
content = settings.FAQParentEN
} else {
content = settings.FAQParentDE
}
}
} else {
// No settings exist, determine type for title
if isEmployeeView {
faqType = "employee"
} else {
faqType = "parent"
}
}
isEmpty := content == ""
util.RenderHTMLOK(c, "faq-page.html", gin.H{
"lang": langStr,
"user": user,
"title": "FAQ",
"faqType": faqType,
"content": util.RenderMarkdownUnsafe(content),
"isEmpty": isEmpty,
})
}
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ShowFeedbackForm displays the feedback form
func ShowFeedbackForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
var user *model.User
if ok {
user = userInterface.(*model.User)
} else {
user = model.NewDummyUser()
}
util.RenderHTMLOK(c, "feedback-form.html", gin.H{
"title": "Wippidu - Feedback",
"lang": langStr,
"user": user,
})
}
// SubmitFeedback handles feedback form submission
func SubmitFeedback(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
var user *model.User
var userEmail string
if ok {
user = userInterface.(*model.User)
userEmail = user.Email
} else {
user = model.NewDummyUser()
userEmail = "anonymous"
}
// Parse form data
category := c.PostForm("category")
feedbackText := c.PostForm("feedback")
subject := c.PostForm("subject")
// Validate inputs
if category == "" || feedbackText == "" || subject == "" {
util.RenderHTML(c, http.StatusBadRequest, "feedback-form.html", gin.H{
"title": "Wippidu - Feedback",
"lang": langStr,
"user": user,
"error": "All fields are required",
})
return
}
// Check if Forgejo service is configured
forgejoService := service.GetForgejoService()
if forgejoService == nil || !forgejoService.IsConfigured() {
util.RenderHTML(c, http.StatusServiceUnavailable, "feedback-form.html", gin.H{
"title": "Wippidu - Feedback",
"lang": langStr,
"user": user,
"error": "Feedback service is not configured",
})
return
}
// Get the app URL from the request
scheme := "https"
if c.Request.TLS == nil {
scheme = "http"
}
appURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
// Get version (from main package, we'll pass it via context or use a global)
version := "unknown"
if v, exists := c.Get("version"); exists {
if vStr, ok := v.(string); ok {
version = vStr
}
}
// Create metadata
metadata := service.FeedbackMetadata{
AppURL: appURL,
Version: version,
UserEmail: userEmail,
Timestamp: time.Now(),
}
// Get appropriate label for category
labelName := service.GetCategoryLabel(category)
// Format the body as markdown
body := formatFeedbackAsMarkdown(category, feedbackText)
// Create the issue
err := forgejoService.CreateIssue(subject, body, metadata, labelName)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "feedback-form.html", gin.H{
"title": "Wippidu - Feedback",
"lang": langStr,
"user": user,
"error": fmt.Sprintf("Failed to submit feedback: %v", err),
})
return
}
// Success - redirect to a thank you page or show success message
util.RenderHTMLOK(c, "feedback-form.html", gin.H{
"title": "Wippidu - Feedback",
"lang": langStr,
"user": user,
"success": true,
})
}
// formatFeedbackAsMarkdown formats the feedback text as markdown based on category
func formatFeedbackAsMarkdown(category, text string) string {
var builder strings.Builder
switch category {
case "bug":
builder.WriteString("## Bug Report\n\n")
builder.WriteString(text)
case "request":
builder.WriteString("## Feature Request\n\n")
builder.WriteString(text)
case "feedback":
builder.WriteString("## General Feedback\n\n")
builder.WriteString(text)
default:
builder.WriteString(text)
}
return builder.String()
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// getUserOrDummyGeneral returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyGeneral(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
// NotImplemented displays a "not implemented" page for features that are not yet available
func NotImplemented(featureName string) gin.HandlerFunc {
return func(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
user := getUserOrDummyGeneral(c)
if isJSON {
c.JSON(http.StatusNotImplemented, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_IMPLEMENTED",
"message": "This feature is not yet implemented",
"feature": featureName,
},
})
return
}
util.RenderHTMLOK(c, "not-implemented.html", gin.H{
"title": "Not Implemented - " + featureName,
"featureName": featureName,
"lang": langStr,
"user": user,
})
}
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// Healthz is a liveness probe. It returns 200 with a tiny JSON body
// as long as the process is alive and the HTTP server is accepting
// connections. Audit #464 flagged the absence of /healthz, /readyz
// and /metrics as a monitoring/autoscaling blocker.
//
// Liveness deliberately does not touch the database: an orchestrator
// that restarts the pod on a transient DB outage would only make the
// outage worse. Use /readyz for readiness.
func Healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
// Readyz is a readiness probe. Returns 200 if the database is
// reachable (ping returns within the gorm session), 503 otherwise.
// Orchestrators use this to decide whether to route traffic; a
// failing readyz pulls the instance from the load balancer but does
// not restart it.
func Readyz(c *gin.Context) {
sqlDB, err := model.DB.DB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable",
"reason": "db handle unavailable",
})
return
}
if err := sqlDB.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable",
"reason": "db ping failed",
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
package controller
// Home controller - handles the main home page with role-based content
// Parents see their children overview, employees see group statistics
//
// [impl->dsn~gui-home~1]
import (
"encoding/json"
"net/http"
"sort"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ViewDateInfo contains information about the date for a view tab
type ViewDateInfo struct {
Date time.Time
Label string // Translation key suffix (e.g., "today", "monday", "tuesday")
IsDefault bool
}
// getEmployeeViewDates calculates which dates to show in the Today/Tomorrow tabs
// based on the current day of the week:
// - Mon-Thu: Today + Tomorrow (next day)
// - Friday: Today + Monday
// - Sat/Sun: Monday (default) + Tuesday
func getEmployeeViewDates(now time.Time) (today ViewDateInfo, tomorrow ViewDateInfo) {
weekday := now.Weekday()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
switch weekday {
case time.Saturday:
// Saturday: Default is Monday, Tomorrow is Tuesday
monday := startOfToday.AddDate(0, 0, 2) // +2 days to Monday
tuesday := startOfToday.AddDate(0, 0, 3) // +3 days to Tuesday
today = ViewDateInfo{Date: monday, Label: "monday", IsDefault: true}
tomorrow = ViewDateInfo{Date: tuesday, Label: "tuesday", IsDefault: false}
case time.Sunday:
// Sunday: Default is Monday, Tomorrow is Tuesday
monday := startOfToday.AddDate(0, 0, 1) // +1 day to Monday
tuesday := startOfToday.AddDate(0, 0, 2) // +2 days to Tuesday
today = ViewDateInfo{Date: monday, Label: "monday", IsDefault: true}
tomorrow = ViewDateInfo{Date: tuesday, Label: "tuesday", IsDefault: false}
case time.Friday:
// Friday: Today + Monday
monday := startOfToday.AddDate(0, 0, 3) // +3 days to Monday
today = ViewDateInfo{Date: startOfToday, Label: "today", IsDefault: true}
tomorrow = ViewDateInfo{Date: monday, Label: "monday", IsDefault: false}
default:
// Mon-Thu: Today + Tomorrow
nextDay := startOfToday.AddDate(0, 0, 1)
today = ViewDateInfo{Date: startOfToday, Label: "today", IsDefault: true}
tomorrow = ViewDateInfo{Date: nextDay, Label: "tomorrow", IsDefault: false}
}
return today, tomorrow
}
// getUserOrDummyHome returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyHome(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
func Home(c *gin.Context) {
isJSON := shouldReturnJSON(c)
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
user, ok := c.Get("User")
if !ok {
// Not authenticated
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Authentication required",
},
})
return
}
// HTML: show login page (always English, no language context)
user = model.NewDummyUser()
logger.Debug("user not logged in, showing login page")
util.RenderHTMLOK(
c,
"login.html",
gin.H{
"user": user,
"title": "Wippidu - please login",
"lang": "en", // Login page is always English
},
)
return
}
id, _ := c.Get("userident")
// fetch user with roles
currentUser := model.User{}
model.DB.Where("id = ?", id).Preload("Roles").First(¤tUser)
logger.Debug("home page requested", "userId", currentUser.ID, "isParent", currentUser.IsParent(), "isEmployee", currentUser.IsEmployee())
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := currentUser.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Admin with selected location - show children from that location
if isAdminWithLocation {
// Fetch location
var location model.Location
if err := model.DB.First(&location, adminLocId).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Location not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - location not found",
"lang": langStr,
"user": currentUser,
})
}
return
}
// Fetch all children at this location with active absence notifications
var children []model.Child
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
model.DB.Preload("Group").
Preload("Group.Location").
Preload("AbsenceNotifications", "from_date <= ? AND to_date >= ? AND deleted_at IS NULL", now, startOfToday).
Where("group_id IN (?)", model.DB.Table("groups").Select("id").Where("location_id = ?", location.ID)).
Order("first_name ASC").
Find(&children)
// Create child data with absence and booking status
type ChildWithAbsence struct {
Child model.Child
HasActiveAbsence bool
ActiveNotification *model.AbsenceNotification
IsNotBooked bool
}
// Group children by their group
type GroupWithChildren struct {
Group model.Group
Children []ChildWithAbsence
}
// Batch-check booking status for all children on today
childIDs := make([]uint, len(children))
for i, child := range children {
childIDs[i] = child.ID
}
bookingStatus := service.GetChildrenBookingStatus(model.DB, childIDs, now)
// Build a map of group ID to group and children
groupMap := make(map[uint]*GroupWithChildren)
for _, child := range children {
if child.GroupId == nil || child.Group == nil {
continue
}
groupID := *child.GroupId
// Create group entry if it doesn't exist
if _, exists := groupMap[groupID]; !exists {
groupMap[groupID] = &GroupWithChildren{
Group: *child.Group,
Children: make([]ChildWithAbsence, 0),
}
}
// Add child to group
hasAbsence := len(child.AbsenceNotifications) > 0
var activeNotif *model.AbsenceNotification
if hasAbsence {
activeNotif = &child.AbsenceNotifications[0]
}
groupMap[groupID].Children = append(groupMap[groupID].Children, ChildWithAbsence{
Child: child,
HasActiveAbsence: hasAbsence,
ActiveNotification: activeNotif,
IsNotBooked: !bookingStatus[child.ID],
})
}
// Convert map to sorted slice
groupedChildren := make([]GroupWithChildren, 0, len(groupMap))
for _, groupData := range groupMap {
groupedChildren = append(groupedChildren, *groupData)
}
// Sort groups by name
sort.Slice(groupedChildren, func(i, j int) bool {
return groupedChildren[i].Group.Name < groupedChildren[j].Group.Name
})
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"user": currentUser,
"location": location,
"groups": groupedChildren,
"isAdminLocation": true,
},
})
return
}
// HTML response for admin with location
util.RenderHTMLOK(
c,
"home.html",
gin.H{
"title": "Home",
"user": currentUser,
"lang": langStr,
"isAdminLocation": true,
"location": location,
"adminGroups": groupedChildren,
},
)
return
}
// Get active role for view selection (supports dual-role users)
activeRole, _ := c.Get("activeRole")
activeRoleStr, _ := activeRole.(string)
// Show employee view if active role is Employee (or any employee-level role)
showEmployeeView := activeRoleStr == "Employee" || activeRoleStr == "GroupLead" || activeRoleStr == "LocationLead"
if showEmployeeView && currentUser.IsEmployee() {
// Fetch employee's groups (handles intranet-linked vs regular employees)
groups, _ := currentUser.GetEmployeeGroups(model.DB)
if len(groups) > 0 {
primaryGroup := groups[0]
// Calculate view dates based on current day of week
now := time.Now()
todayTab, tomorrowTab := getEmployeeViewDates(now)
// Get the view parameter (default to "today")
viewParam := c.DefaultQuery("view", "today")
if viewParam != "today" && viewParam != "tomorrow" {
viewParam = "today"
}
// Determine which date to query based on view parameter
var viewDate time.Time
if viewParam == "tomorrow" {
viewDate = tomorrowTab.Date
} else {
viewDate = todayTab.Date
}
// Calculate date range for absence query
startOfViewDate := time.Date(viewDate.Year(), viewDate.Month(), viewDate.Day(), 0, 0, 0, 0, viewDate.Location())
endOfViewDate := startOfViewDate.AddDate(0, 0, 1)
// Fetch children in the primary group with absence notifications for the view date
// Query: from_date < endOfViewDate (absence starts before end of view day)
// AND to_date >= startOfViewDate (absence extends into or past start of view day)
var children []model.Child
model.DB.Preload("Group.Location").
Preload("AbsenceNotifications", "from_date < ? AND to_date >= ? AND deleted_at IS NULL", endOfViewDate, startOfViewDate).
Where("group_id = ?", primaryGroup.ID).
Order("first_name ASC").
Find(&children)
// Create child data with absence and booking status
type ChildWithAbsence struct {
Child model.Child
HasActiveAbsence bool
ActiveNotification *model.AbsenceNotification
IsNotBooked bool
}
// Batch-check booking status for all children on the view date
childIDs := make([]uint, len(children))
for i, child := range children {
childIDs[i] = child.ID
}
bookingStatus := service.GetChildrenBookingStatus(model.DB, childIDs, viewDate)
childrenWithAbsences := make([]ChildWithAbsence, len(children))
for i, child := range children {
hasAbsence := len(child.AbsenceNotifications) > 0
var activeNotif *model.AbsenceNotification
if hasAbsence {
activeNotif = &child.AbsenceNotifications[0]
}
childrenWithAbsences[i] = ChildWithAbsence{
Child: child,
HasActiveAbsence: hasAbsence,
ActiveNotification: activeNotif,
IsNotBooked: !bookingStatus[child.ID],
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"user": currentUser,
"primaryGroup": primaryGroup,
"children": childrenWithAbsences,
"isEmployee": true,
"selectedView": viewParam,
"todayTab": todayTab,
"tomorrowTab": tomorrowTab,
},
})
return
}
// Check if user can access all location children based on their role and location settings
allowLocationView := false
if primaryGroup.Location != nil {
allowLocationView = primaryGroup.Location.CanEmployeeAccessAllChildren(¤tUser)
}
// HTML response for employee
util.RenderHTMLOK(
c,
"home.html",
gin.H{
"title": "Home",
"user": currentUser,
"lang": langStr,
"isEmployee": true,
"primaryGroup": primaryGroup,
"employeeChildren": childrenWithAbsences,
"allowLocationView": allowLocationView,
"selectedView": viewParam,
"todayTab": todayTab,
"tomorrowTab": tomorrowTab,
},
)
return
}
}
// Parent view (existing logic)
parent := model.User{}
model.DB.Where("id = ?", id).
Preload("Roles").
First(&parent)
// Get only children with valid enrollment dates (filters by user_children validity)
validChildren, err := model.GetValidChildrenForUser(model.DB, parent.ID)
if err != nil {
logger.Error("failed to get valid children for user", "userId", parent.ID, "error", err)
validChildren = []*model.Child{}
}
parent.Children = validChildren
// If parent has no valid children, show informational message
if len(validChildren) == 0 {
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"user": parent, "hasValidChildren": false},
})
return
}
util.RenderHTMLOK(c, "home.html", gin.H{
"title": "Home",
"user": parent,
"lang": langStr,
"noValidChildren": true,
})
return
}
// Sort children alphabetically by first name
sort.Slice(parent.Children, func(i, j int) bool {
return parent.Children[i].FirstName < parent.Children[j].FirstName
})
logger.Debug("parent home view", "userId", parent.ID, "childrenCount", len(parent.Children))
// Create child data with absence status for parent view
type ChildWithAbsence struct {
Child *model.Child
HasActiveAbsence bool
ActiveNotification *model.AbsenceNotification
}
childrenWithAbsences := make([]ChildWithAbsence, len(parent.Children))
for i, child := range parent.Children {
hasAbsence := len(child.AbsenceNotifications) > 0
var activeNotif *model.AbsenceNotification
if hasAbsence {
activeNotif = &child.AbsenceNotifications[0]
}
childrenWithAbsences[i] = ChildWithAbsence{
Child: child,
HasActiveAbsence: hasAbsence,
ActiveNotification: activeNotif,
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"user": parent,
"children": childrenWithAbsences,
},
})
return
}
// HTML response for parent
data, _ := json.MarshalIndent(parent, "", " ")
util.RenderHTMLOK(
c,
"home.html",
gin.H{
"headline": "Your Children at Wippidu",
"user": parent,
"title": "Home",
"content": string(data),
"lang": langStr, // Pass language to template
"parentChildren": childrenWithAbsences,
},
)
}
package controller
import (
"net/http"
"strconv"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// auditLog provides a component-specific logger for impersonation audit events
var auditLog = logger.WithComponent("impersonation")
// StartImpersonation allows an admin to impersonate another user
func StartImpersonation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Must be admin (and not already impersonating)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Check if already impersonating
if _, isImpersonating := c.Get("isImpersonating"); isImpersonating {
// Already impersonating, redirect back
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
// Get target user ID from URL
targetUserIDStr := c.Param("userid")
targetUserID, err := strconv.ParseUint(targetUserIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load target user
var targetUser model.User
if err := model.DB.Preload("Roles").First(&targetUser, targetUserID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Security: Cannot impersonate another admin
if targetUser.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Log impersonation (for audit trail)
auditLog.Info("admin started impersonation",
"adminId", user.ID,
"adminEmail", user.Email,
"targetUserId", targetUser.ID,
"targetUserEmail", targetUser.Email)
// Set impersonation cookie (4 hours expiration)
c.SetCookie("impersonate_user", strconv.FormatUint(uint64(targetUser.ID), 10),
60*60*4, "/", "", util.SecureCookies(), true)
// Redirect to home to see impersonated view
c.Redirect(http.StatusFound, "/"+langStr+"/")
}
// StopImpersonation ends the impersonation session
func StopImpersonation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
// Get original admin from context for logging
originalAdminInterface, ok := c.Get("originalAdmin")
var adminID uint
var adminEmail string
if ok {
originalAdmin := originalAdminInterface.(*model.User)
adminID = originalAdmin.ID
adminEmail = originalAdmin.Email
}
// Get impersonated user info for logging
userInterface, _ := c.Get("User")
var impersonatedID uint
var impersonatedEmail string
if user, ok := userInterface.(*model.User); ok {
impersonatedID = user.ID
impersonatedEmail = user.Email
}
// Log impersonation end
auditLog.Info("admin stopped impersonation",
"adminId", adminID,
"adminEmail", adminEmail,
"targetUserId", impersonatedID,
"targetUserEmail", impersonatedEmail)
// Clear impersonation cookie
c.SetCookie("impersonate_user", "", -1, "/", "", util.SecureCookies(), true)
// Redirect to admin users page
c.Redirect(http.StatusFound, "/"+langStr+"/admin/users")
}
package controller
import (
"fmt"
"html/template"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
)
// setupGinWithTemplates initializes a Gin router with actual templates loaded
// This is critical for integration tests that verify rendered HTML output
func setupGinWithTemplates(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.Default()
// CRITICAL: Initialize i18n translations before loading templates
// Templates use {{ t .lang "key" }} which requires i18n to be initialized
err := i18n.Init()
require.NoError(t, err, "Failed to initialize i18n translations")
// Setup i18n template functions
funcs := setupI18nFunctions()
router.SetFuncMap(funcs)
// Load actual templates from the app-server directory
// Templates are located relative to the backend root
router.LoadHTMLGlob("../../cmd/app-server/templates/**/*")
return router
}
// setupI18nFunctions creates a template.FuncMap with all functions needed by templates
// This includes i18n translation and utility functions like map
func setupI18nFunctions() template.FuncMap {
return template.FuncMap{
"t": func(lang, key string) string {
// Use actual i18n translation
return i18n.Translate(lang, key)
},
// tp translates with plural support based on count
"tp": func(lang, key string, count int) string {
return i18n.TranslatePlural(lang, key, count)
},
"map": func(pairs ...any) (map[string]any, error) {
// Helper function to create maps in templates
// Usage: {{ template "foo" (map "key1" "value1" "key2" "value2") }}
if len(pairs)%2 != 0 {
return nil, nil // Return empty map instead of error for tests
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue // Skip invalid keys
}
m[key] = pairs[i+1]
}
return m, nil
},
"version": func() string {
// Return test version for integration tests
return "test-version"
},
// isDatePast checks if a date is in the past
"isDatePast": func(t *time.Time) bool {
if t == nil {
return false
}
return t.Before(time.Now())
},
// isDateSoon checks if a date is within the next 30 days
"isDateSoon": func(t *time.Time) bool {
if t == nil {
return false
}
now := time.Now()
thirtyDaysFromNow := now.AddDate(0, 0, 30)
return t.After(now) && t.Before(thirtyDaysFromNow)
},
// mod returns a % b (modulo operation)
"mod": func(a, b int) int {
return a % b
},
// safeJS marks a string as safe JavaScript (bypasses HTML escaping)
"safeJS": func(s string) template.JS {
return template.JS(s)
},
// deref dereferences a pointer to uint for template comparisons
"deref": func(p *uint) uint {
if p == nil {
return 0
}
return *p
},
// truncate returns the first n characters of a string, appending "..." if truncated
"truncate": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
// add returns the sum of two integers (for template arithmetic)
"add": func(a, b int) int {
return a + b
},
// formatBytes formats a file size in bytes to a human-readable string
"formatBytes": func(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
)
switch {
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
},
}
}
// createFullTestContext creates a complete Gin test context with:
// - HTTP recorder for capturing response
// - Gin context with proper request setup and router
// - User authentication simulation
// - Language setting
func createFullTestContext(t *testing.T, router *gin.Engine, method, path string, user *model.User, lang string) (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
req, err := http.NewRequest(method, path, nil)
require.NoError(t, err)
// CRITICAL: Create context from the router, not standalone
// This ensures the context has access to the router's template engine
c := gin.CreateTestContextOnly(w, router)
c.Request = req
// Simulate authentication middleware
if user != nil {
c.Set("User", user)
c.Set("userident", user.ID)
// Set activeRole to mirror authorization middleware behavior
// This is critical for controllers that check activeRole for view selection
if user.IsParent() && user.IsEmployee() {
c.Set("activeRole", "Parent") // Default for dual-role users
} else if len(user.Roles) > 0 {
c.Set("activeRole", user.Roles[0].Name)
}
}
// Set language (default to German if not specified)
if lang == "" {
lang = "de"
}
c.Set("language", lang)
c.Params = gin.Params{gin.Param{Key: "lang", Value: lang}}
// Set empty badges context for templates that use .badges
c.Set("badges", service.NotificationBadges{})
return c, w
}
// createFullTestContextWithBody creates a complete test context with a request body
// Useful for testing POST endpoints with form data
func createFullTestContextWithBody(t *testing.T, router *gin.Engine, method, path string, user *model.User, lang string, body io.Reader) (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
req, err := http.NewRequest(method, path, body)
require.NoError(t, err)
// CRITICAL: Create context from the router, not standalone
// This ensures the context has access to the router's template engine
c := gin.CreateTestContextOnly(w, router)
c.Request = req
// Simulate authentication middleware
if user != nil {
c.Set("User", user)
c.Set("userident", user.ID)
// Set activeRole to mirror authorization middleware behavior
if user.IsParent() && user.IsEmployee() {
c.Set("activeRole", "Parent")
} else if len(user.Roles) > 0 {
c.Set("activeRole", user.Roles[0].Name)
}
}
// Set language (default to German if not specified)
if lang == "" {
lang = "de"
}
c.Set("language", lang)
c.Params = gin.Params{gin.Param{Key: "lang", Value: lang}}
// Set empty badges context for templates that use .badges
c.Set("badges", service.NotificationBadges{})
return c, w
}
// HTMLParsingUtils provides utilities for parsing and querying rendered HTML
// parseHTML parses an HTML string and returns the root document node
func parseHTML(t *testing.T, htmlContent string) *html.Node {
doc, err := html.Parse(strings.NewReader(htmlContent))
require.NoError(t, err, "Failed to parse HTML")
return doc
}
// findElementByClass searches for the first element with the given class name
func findElementByClass(n *html.Node, className string) *html.Node {
if n.Type == html.ElementNode {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, className) {
return n
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if result := findElementByClass(c, className); result != nil {
return result
}
}
return nil
}
// findAllElementsByClass searches for all elements with the given class name
func findAllElementsByClass(n *html.Node, className string) []*html.Node {
var results []*html.Node
var traverse func(*html.Node)
traverse = func(node *html.Node) {
if node.Type == html.ElementNode {
for _, attr := range node.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, className) {
results = append(results, node)
break
}
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(n)
return results
}
// getTextContent extracts all text content from an HTML node and its children
func getTextContent(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var text strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
text.WriteString(getTextContent(c))
}
return text.String()
}
// countOccurrences counts how many times a substring appears in an HTML document
func countOccurrences(htmlContent, substring string) int {
return strings.Count(htmlContent, substring)
}
// assertHTMLContains is a test helper that verifies HTML contains expected content
func assertHTMLContains(t *testing.T, htmlContent, expected, message string) {
if !strings.Contains(htmlContent, expected) {
t.Errorf("%s\nExpected HTML to contain: %s\nHTML preview: %s...",
message, expected, truncate(htmlContent, 500))
}
}
// assertHTMLNotContains is a test helper that verifies HTML does not contain content
func assertHTMLNotContains(t *testing.T, htmlContent, unexpected, message string) {
if strings.Contains(htmlContent, unexpected) {
t.Errorf("%s\nExpected HTML to NOT contain: %s", message, unexpected)
}
}
// truncate limits a string to maxLength characters
func truncate(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
return s[:maxLength] + "..."
}
package controller
// Intranet API controller - receives data from the Wippidu intranet system
// Handles sync of children, groups, locations, employees, and relationships
//
// [impl->dsn~import-export-design~1]
import (
"log"
"net/http"
"strings"
"time"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// getTokenInfo extracts token info from context for logging
func getTokenInfo(c *gin.Context) (tokenID uint, tokenName string) {
if apiToken, exists := c.Get("apiToken"); exists {
if token, ok := apiToken.(*model.APIToken); ok {
return token.ID, token.Name
}
}
return 0, "unknown"
}
// =============================================================================
// Request Payload Structures
// =============================================================================
// IntranetPersonPayload represents incoming person data from intranet
type IntranetPersonPayload struct {
ExternalID string `json:"external_id" binding:"required"`
Vorname string `json:"vorname"`
Nachname string `json:"nachname"`
Geburtstag *string `json:"geburtstag"` // Format: YYYY-MM-DD
Rolle string `json:"rolle"` // parent, child, employee
Comments string `json:"comments"`
}
// IntranetPersonBatch allows batch submission of persons
type IntranetPersonBatch struct {
Records []IntranetPersonPayload `json:"records" binding:"required,dive"`
}
// IntranetParentChildPayload represents parent-child relationship data
type IntranetParentChildPayload struct {
ElternID string `json:"eltern_id" binding:"required"`
KindID string `json:"kind_id" binding:"required"`
Von *string `json:"von"` // Format: YYYY-MM-DD
Bis *string `json:"bis"` // Format: YYYY-MM-DD
}
// IntranetParentChildBatch allows batch submission of parent-child relationships
type IntranetParentChildBatch struct {
Records []IntranetParentChildPayload `json:"records" binding:"required,dive"`
}
// IntranetBelegungPayload represents group assignment data (Belegungstabelle)
type IntranetBelegungPayload struct {
IDKrp *string `json:"id_krp"` // For krp endpoint
IDUe3 *string `json:"id_ue3"` // For ue3 endpoint
KindID string `json:"kind_id" binding:"required"`
GruppenID string `json:"gruppen_id" binding:"required"`
TageBinaer int `json:"tage_binaer"`
AnzahlTage int `json:"anzahl_tage"`
Von *string `json:"von"` // Format: YYYY-MM-DD
Bis *string `json:"bis"` // Format: YYYY-MM-DD
Status int `json:"status"`
Comments string `json:"comments"`
SomeID *string `json:"some_id"`
}
// IntranetBelegungBatch allows batch submission of group assignments
type IntranetBelegungBatch struct {
Records []IntranetBelegungPayload `json:"records" binding:"required,dive"`
}
// IntranetEmployeePayload represents employee data
type IntranetEmployeePayload struct {
ExternalID string `json:"external_id" binding:"required"`
Name string `json:"name"`
Vorname string `json:"vorname"`
Nachname string `json:"nachname"`
Qualifikation string `json:"qualifikation"`
Von *string `json:"von"` // Format: YYYY-MM-DD
Bis *string `json:"bis"` // Format: YYYY-MM-DD
}
// IntranetEmployeeBatch allows batch submission of employees
type IntranetEmployeeBatch struct {
Records []IntranetEmployeePayload `json:"records" binding:"required,dive"`
}
// IntranetLocationPayload represents location/facility data
type IntranetLocationPayload struct {
EinrichtungsID string `json:"einrichtungs_id" binding:"required"`
Name string `json:"name"`
Adresse string `json:"adresse"`
Reihenfolge int `json:"reihenfolge"`
GVon *string `json:"g_von"` // Format: YYYY-MM-DD
GBis *string `json:"g_bis"` // Format: YYYY-MM-DD
}
// IntranetLocationBatch allows batch submission of locations
type IntranetLocationBatch struct {
Records []IntranetLocationPayload `json:"records" binding:"required,dive"`
}
// IntranetGroupPayload represents group data
type IntranetGroupPayload struct {
GruppenID string `json:"gruppen_id" binding:"required"`
EinrichtungsID string `json:"einrichtungs_id" binding:"required"`
EArtID *string `json:"e_art_id"`
Reihenfolge int `json:"reihenfolge"`
Name string `json:"name"`
OeZ string `json:"oez"` // Opening hours
GVon *string `json:"g_von"`
GBis *string `json:"g_bis"`
}
// IntranetGroupBatch allows batch submission of groups
type IntranetGroupBatch struct {
Records []IntranetGroupPayload `json:"records" binding:"required,dive"`
}
// IntranetLocationLeadPayload represents location leadership data
type IntranetLocationLeadPayload struct {
EinrichtungsID string `json:"einrichtungs_id" binding:"required"`
MitarbeiterID string `json:"mitarbeiter_id" binding:"required"`
StellvertreterID *string `json:"stellvertreter_id"`
StellvertreterAnteil *int `json:"stellvertreter_anteil"`
GVon *string `json:"g_von"`
GBis *string `json:"g_bis"`
}
// IntranetLocationLeadBatch allows batch submission of location leads
type IntranetLocationLeadBatch struct {
Records []IntranetLocationLeadPayload `json:"records" binding:"required,dive"`
}
// IntranetGroupLeadPayload represents group leadership data
type IntranetGroupLeadPayload struct {
GruppenID string `json:"gruppen_id" binding:"required"`
MitarbeiterID string `json:"mitarbeiter_id" binding:"required"`
GVon *string `json:"g_von"`
GBis *string `json:"g_bis"`
}
// IntranetGroupLeadBatch allows batch submission of group leads
type IntranetGroupLeadBatch struct {
Records []IntranetGroupLeadPayload `json:"records" binding:"required,dive"`
}
// =============================================================================
// Response Structures
// =============================================================================
// SyncResultItem represents the result of syncing a single record
type SyncResultItem struct {
Identifier string `json:"identifier"` // The external ID or composite key
Status string `json:"status"` // "inserted", "updated", "error"
Error string `json:"error,omitempty"` // Error message if status is "error"
}
// SyncResponse represents the response for a sync operation
type SyncResponse struct {
Success bool `json:"success"`
Received int `json:"received"`
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Errors int `json:"errors"`
Results []SyncResultItem `json:"results"`
}
// =============================================================================
// Helper Functions
// =============================================================================
// parseDateString parses a date string in YYYY.mm.dd format
func parseDateString(dateStr *string) *time.Time {
if dateStr == nil || *dateStr == "" {
return nil
}
if parsed, err := time.Parse("2006.01.02", *dateStr); err == nil {
return &parsed
}
return nil
}
// logSyncReceive creates a SyncReceiveLog entry for tracking intranet API data reception
func logSyncReceive(dataType string, response *SyncResponse, tokenName string, clientIP string) {
var errorDetails []string
for _, r := range response.Results {
if r.Status == "error" && r.Error != "" {
errorDetails = append(errorDetails, r.Identifier+": "+r.Error)
}
}
logEntry := model.SyncReceiveLog{
DataType: dataType,
ReceivedAt: time.Now(),
RecordsReceived: response.Received,
Inserted: response.Inserted,
Updated: response.Updated,
Errored: response.Errors,
TokenName: tokenName,
SourceIP: clientIP,
}
if len(errorDetails) > 0 {
joined := strings.Join(errorDetails, "\n")
if len(joined) > 4000 {
joined = joined[:4000] + "\n... (truncated)"
}
logEntry.ErrorDetails = joined
}
if err := model.DB.Create(&logEntry).Error; err != nil {
log.Printf("[INTRANET-SYNC] Failed to create SyncReceiveLog for %s: %v", dataType, err)
}
}
// =============================================================================
// API Handlers
// =============================================================================
// IntranetSyncPerson handles POST /api/intranet/person
// Upserts person records by external_id
func IntranetSyncPerson(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] Person: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetPersonBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] Person: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] Person: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
result := SyncResultItem{Identifier: p.ExternalID}
syncPerson := model.SyncPerson{
ExternalID: p.ExternalID,
Vorname: p.Vorname,
Nachname: p.Nachname,
Geburtstag: parseDateString(p.Geburtstag),
Rolle: p.Rolle,
Comments: p.Comments,
SyncedAt: time.Now(),
}
// Check if record exists
var existing model.SyncPerson
dbResult := model.DB.Where("external_id = ?", p.ExternalID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncPerson.ID = existing.ID
syncPerson.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncPerson).Error; err != nil {
log.Printf("[INTRANET-SYNC] Person: DB error updating ExternalID=%s: %v - TokenID=%d", p.ExternalID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Person: Updated ExternalID=%s Rolle=%s - TokenID=%d", p.ExternalID, p.Rolle, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncPerson).Error; err != nil {
log.Printf("[INTRANET-SYNC] Person: DB error inserting ExternalID=%s: %v - TokenID=%d", p.ExternalID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Person: Inserted ExternalID=%s Rolle=%s - TokenID=%d", p.ExternalID, p.Rolle, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] Person: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("person", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncParentChild handles POST /api/intranet/parent-child
// Upserts parent-child relationships by eltern_id + kind_id combination
func IntranetSyncParentChild(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] ParentChild: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetParentChildBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] ParentChild: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] ParentChild: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
identifier := p.ElternID + "-" + p.KindID
result := SyncResultItem{Identifier: identifier}
syncRecord := model.SyncParentChild{
ElternID: p.ElternID,
KindID: p.KindID,
Von: parseDateString(p.Von),
Bis: parseDateString(p.Bis),
SyncedAt: time.Now(),
}
// Check if record exists (by composite key)
var existing model.SyncParentChild
dbResult := model.DB.Where("eltern_id = ? AND kind_id = ?", p.ElternID, p.KindID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] ParentChild: DB error updating %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] ParentChild: Updated %s - TokenID=%d", identifier, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] ParentChild: DB error inserting %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] ParentChild: Inserted %s - TokenID=%d", identifier, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] ParentChild: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("parent_child", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncBelegungKrp handles POST /api/intranet/belegung/krp
// Upserts belegung records by id_krp
func IntranetSyncBelegungKrp(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] BelegungKrp: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetBelegungBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] BelegungKrp: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] BelegungKrp: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
// For krp endpoint, id_krp is required
if p.IDKrp == nil || *p.IDKrp == "" {
log.Printf("[INTRANET-SYNC] BelegungKrp: Missing id_krp for record - TokenID=%d", tokenID)
result := SyncResultItem{
Identifier: "unknown",
Status: "error",
Error: "id_krp is required for krp endpoint",
}
response.Results = append(response.Results, result)
response.Errors++
continue
}
result := SyncResultItem{Identifier: *p.IDKrp}
syncRecord := model.SyncBelegung{
IDKrp: p.IDKrp,
KindID: p.KindID,
GruppenID: p.GruppenID,
TageBinaer: p.TageBinaer,
AnzahlTage: p.AnzahlTage,
Von: parseDateString(p.Von),
Bis: parseDateString(p.Bis),
Status: p.Status,
Comments: p.Comments,
SomeID: p.SomeID,
SyncedAt: time.Now(),
}
// Check if record exists by id_krp
var existing model.SyncBelegung
dbResult := model.DB.Where("id_krp = ?", *p.IDKrp).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.IDInternal = existing.IDInternal
syncRecord.CreatedAt = existing.CreatedAt
// Preserve IDUe3 if it was set
syncRecord.IDUe3 = existing.IDUe3
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] BelegungKrp: DB error updating IDKrp=%s: %v - TokenID=%d", *p.IDKrp, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] BelegungKrp: Updated IDKrp=%s KindID=%s GruppenID=%s - TokenID=%d", *p.IDKrp, p.KindID, p.GruppenID, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] BelegungKrp: DB error inserting IDKrp=%s: %v - TokenID=%d", *p.IDKrp, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] BelegungKrp: Inserted IDKrp=%s KindID=%s GruppenID=%s - TokenID=%d", *p.IDKrp, p.KindID, p.GruppenID, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] BelegungKrp: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("belegung_krp", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncBelegungUe3 handles POST /api/intranet/belegung/ue3
// Upserts belegung records by id_ue3
func IntranetSyncBelegungUe3(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] BelegungUe3: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetBelegungBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] BelegungUe3: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] BelegungUe3: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
// For ue3 endpoint, id_ue3 is required
if p.IDUe3 == nil || *p.IDUe3 == "" {
log.Printf("[INTRANET-SYNC] BelegungUe3: Missing id_ue3 for record - TokenID=%d", tokenID)
result := SyncResultItem{
Identifier: "unknown",
Status: "error",
Error: "id_ue3 is required for ue3 endpoint",
}
response.Results = append(response.Results, result)
response.Errors++
continue
}
result := SyncResultItem{Identifier: *p.IDUe3}
syncRecord := model.SyncBelegung{
IDUe3: p.IDUe3,
KindID: p.KindID,
GruppenID: p.GruppenID,
TageBinaer: p.TageBinaer,
AnzahlTage: p.AnzahlTage,
Von: parseDateString(p.Von),
Bis: parseDateString(p.Bis),
Status: p.Status,
Comments: p.Comments,
SomeID: p.SomeID,
SyncedAt: time.Now(),
}
// Check if record exists by id_ue3
var existing model.SyncBelegung
dbResult := model.DB.Where("id_ue3 = ?", *p.IDUe3).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.IDInternal = existing.IDInternal
syncRecord.CreatedAt = existing.CreatedAt
// Preserve IDKrp if it was set
syncRecord.IDKrp = existing.IDKrp
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] BelegungUe3: DB error updating IDUe3=%s: %v - TokenID=%d", *p.IDUe3, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] BelegungUe3: Updated IDUe3=%s KindID=%s GruppenID=%s - TokenID=%d", *p.IDUe3, p.KindID, p.GruppenID, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] BelegungUe3: DB error inserting IDUe3=%s: %v - TokenID=%d", *p.IDUe3, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] BelegungUe3: Inserted IDUe3=%s KindID=%s GruppenID=%s - TokenID=%d", *p.IDUe3, p.KindID, p.GruppenID, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] BelegungUe3: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("belegung_ue3", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncEmployee handles POST /api/intranet/employee
// Upserts employee records by external_id
func IntranetSyncEmployee(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] Employee: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetEmployeeBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] Employee: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] Employee: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
result := SyncResultItem{Identifier: p.ExternalID}
syncRecord := model.SyncEmployee{
ExternalID: p.ExternalID,
Name: p.Name,
Vorname: p.Vorname,
Nachname: p.Nachname,
Qualifikation: p.Qualifikation,
Von: parseDateString(p.Von),
Bis: parseDateString(p.Bis),
SyncedAt: time.Now(),
}
// Check if record exists
var existing model.SyncEmployee
dbResult := model.DB.Where("external_id = ?", p.ExternalID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Employee: DB error updating ExternalID=%s: %v - TokenID=%d", p.ExternalID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Employee: Updated ExternalID=%s Name=%s - TokenID=%d", p.ExternalID, p.Name, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Employee: DB error inserting ExternalID=%s: %v - TokenID=%d", p.ExternalID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Employee: Inserted ExternalID=%s Name=%s - TokenID=%d", p.ExternalID, p.Name, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] Employee: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("employee", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncLocation handles POST /api/intranet/location
// Upserts location records by einrichtungs_id
func IntranetSyncLocation(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] Location: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetLocationBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] Location: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] Location: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
result := SyncResultItem{Identifier: p.EinrichtungsID}
syncRecord := model.SyncLocation{
EinrichtungsID: p.EinrichtungsID,
Name: p.Name,
Adresse: p.Adresse,
Reihenfolge: p.Reihenfolge,
GVon: parseDateString(p.GVon),
GBis: parseDateString(p.GBis),
SyncedAt: time.Now(),
}
// Check if record exists
var existing model.SyncLocation
dbResult := model.DB.Where("einrichtungs_id = ?", p.EinrichtungsID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Location: DB error updating EinrichtungsID=%s: %v - TokenID=%d", p.EinrichtungsID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Location: Updated EinrichtungsID=%s Name=%s - TokenID=%d", p.EinrichtungsID, p.Name, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Location: DB error inserting EinrichtungsID=%s: %v - TokenID=%d", p.EinrichtungsID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Location: Inserted EinrichtungsID=%s Name=%s - TokenID=%d", p.EinrichtungsID, p.Name, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] Location: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("location", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncGroup handles POST /api/intranet/group
// Upserts group records by gruppen_id
func IntranetSyncGroup(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] Group: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetGroupBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] Group: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] Group: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
result := SyncResultItem{Identifier: p.GruppenID}
syncRecord := model.SyncGroup{
GruppenID: p.GruppenID,
EinrichtungsID: p.EinrichtungsID,
EArtID: p.EArtID,
Reihenfolge: p.Reihenfolge,
Name: p.Name,
OeZ: p.OeZ,
GVon: parseDateString(p.GVon),
GBis: parseDateString(p.GBis),
SyncedAt: time.Now(),
}
// Check if record exists
var existing model.SyncGroup
dbResult := model.DB.Where("gruppen_id = ?", p.GruppenID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Group: DB error updating GruppenID=%s: %v - TokenID=%d", p.GruppenID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Group: Updated GruppenID=%s Name=%s EinrichtungsID=%s - TokenID=%d", p.GruppenID, p.Name, p.EinrichtungsID, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] Group: DB error inserting GruppenID=%s: %v - TokenID=%d", p.GruppenID, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] Group: Inserted GruppenID=%s Name=%s EinrichtungsID=%s - TokenID=%d", p.GruppenID, p.Name, p.EinrichtungsID, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] Group: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("group", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncLocationLead handles POST /api/intranet/location-lead
// Upserts location lead records by einrichtungs_id + mitarbeiter_id combination
func IntranetSyncLocationLead(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] LocationLead: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetLocationLeadBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] LocationLead: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] LocationLead: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
identifier := p.EinrichtungsID + "-" + p.MitarbeiterID
result := SyncResultItem{Identifier: identifier}
syncRecord := model.SyncLocationLead{
EinrichtungsID: p.EinrichtungsID,
MitarbeiterID: p.MitarbeiterID,
StellvertreterID: p.StellvertreterID,
StellvertreterAnteil: p.StellvertreterAnteil,
GVon: parseDateString(p.GVon),
GBis: parseDateString(p.GBis),
SyncedAt: time.Now(),
}
// Check if record exists (by composite key)
var existing model.SyncLocationLead
dbResult := model.DB.Where("einrichtungs_id = ? AND mitarbeiter_id = ?", p.EinrichtungsID, p.MitarbeiterID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] LocationLead: DB error updating %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] LocationLead: Updated %s - TokenID=%d", identifier, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] LocationLead: DB error inserting %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] LocationLead: Inserted %s - TokenID=%d", identifier, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] LocationLead: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("location_lead", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
// IntranetSyncGroupLead handles POST /api/intranet/group-lead
// Upserts group lead records by gruppen_id + mitarbeiter_id combination
func IntranetSyncGroupLead(c *gin.Context) {
tokenID, tokenName := getTokenInfo(c)
clientIP := c.ClientIP()
log.Printf("[INTRANET-SYNC] GroupLead: START - TokenID=%d TokenName=%s IP=%s", tokenID, tokenName, clientIP)
var batch IntranetGroupLeadBatch
if err := c.ShouldBindJSON(&batch); err != nil {
log.Printf("[INTRANET-SYNC] GroupLead: FAILED - Invalid payload: %v - TokenID=%d IP=%s", err, tokenID, clientIP)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_PAYLOAD",
"message": err.Error(),
},
})
return
}
log.Printf("[INTRANET-SYNC] GroupLead: Received %d records - TokenID=%d IP=%s", len(batch.Records), tokenID, clientIP)
response := SyncResponse{
Success: true,
Received: len(batch.Records),
Results: make([]SyncResultItem, 0, len(batch.Records)),
}
for _, p := range batch.Records {
identifier := p.GruppenID + "-" + p.MitarbeiterID
result := SyncResultItem{Identifier: identifier}
syncRecord := model.SyncGroupLead{
GruppenID: p.GruppenID,
MitarbeiterID: p.MitarbeiterID,
GVon: parseDateString(p.GVon),
GBis: parseDateString(p.GBis),
SyncedAt: time.Now(),
}
// Check if record exists (by composite key)
var existing model.SyncGroupLead
dbResult := model.DB.Where("gruppen_id = ? AND mitarbeiter_id = ?", p.GruppenID, p.MitarbeiterID).First(&existing)
if dbResult.Error == nil {
// Update existing
syncRecord.ID = existing.ID
syncRecord.CreatedAt = existing.CreatedAt
if err := model.DB.Save(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] GroupLead: DB error updating %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] GroupLead: Updated %s - TokenID=%d", identifier, tokenID)
result.Status = "updated"
response.Updated++
}
} else {
// Insert new
if err := model.DB.Create(&syncRecord).Error; err != nil {
log.Printf("[INTRANET-SYNC] GroupLead: DB error inserting %s: %v - TokenID=%d", identifier, err, tokenID)
result.Status = "error"
result.Error = err.Error()
response.Errors++
} else {
log.Printf("[INTRANET-SYNC] GroupLead: Inserted %s - TokenID=%d", identifier, tokenID)
result.Status = "inserted"
response.Inserted++
}
}
response.Results = append(response.Results, result)
}
if response.Errors > 0 {
response.Success = false
}
log.Printf("[INTRANET-SYNC] GroupLead: COMPLETE - Received=%d Inserted=%d Updated=%d Errors=%d - TokenID=%d IP=%s",
response.Received, response.Inserted, response.Updated, response.Errors, tokenID, clientIP)
logSyncReceive("group_lead", &response, tokenName, clientIP)
c.JSON(http.StatusOK, response)
}
package controller
import (
"net/http"
"os"
"strconv"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ShowGenerateInvitationForm displays the form to generate an invitation code
func ShowGenerateInvitationForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLead, LocationLead, or Admin can generate invitation codes
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
"user": user,
})
return
}
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"lang": langStr,
"error": "Invalid child ID",
"user": user,
})
return
}
// Check permission to generate invitations for this child
invService := service.NewInvitationService(model.DB)
canGenerate, err := invService.CanUserGenerateInvitation(user.ID, uint(childID))
if err != nil || !canGenerate {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
"user": user,
})
return
}
// Get child data
child, err := service.GetChildByID(model.DB, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Not Found",
"lang": langStr,
"user": user,
})
return
}
// Get existing invitations for this child
invitations, _ := invService.GetInvitationsForChild(uint(childID))
relationshipRoles := service.GetAllRelationshipRoles()
// Check for success/error messages
success := c.Query("success")
errorMsg := c.Query("error")
newCode := c.Query("code")
// If we have a new code, get its full details
var newInvitation *model.InvitationCode
if newCode != "" {
newInvitation, _ = invService.GetInvitationByCode(newCode)
}
// Build base URL for invitation links
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
fullBaseURL := scheme + "://" + baseURL
util.RenderHTMLOK(c, "invitation-generate.html", gin.H{
"title": "invitation.generate.title",
"lang": langStr,
"user": user,
"child": child,
"invitations": invitations,
"relationshipRoles": relationshipRoles,
"success": success,
"error": errorMsg,
"newInvitation": newInvitation,
"baseURL": fullBaseURL,
})
}
// GenerateInvitationCode creates a new invitation code
func GenerateInvitationCode(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLead, LocationLead, or Admin can generate invitation codes
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
childIDStr := c.Param("id")
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
relationshipRole := c.PostForm("relationship_role")
if relationshipRole == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?error=missing_role")
return
}
invService := service.NewInvitationService(model.DB)
// Check permission
canGenerate, err := invService.CanUserGenerateInvitation(user.ID, uint(childID))
if err != nil || !canGenerate {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
invitation, err := invService.GenerateInvitationCode(uint(childID), model.RelationshipRole(relationshipRole), user.ID)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?error=generation_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?success=created&code="+invitation.Code)
}
// RevokeInvitationCode revokes an existing invitation code
func RevokeInvitationCode(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only GroupLead, LocationLead, or Admin can revoke invitation codes
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
childIDStr := c.Param("id")
codeIDStr := c.Param("codeid")
codeID, err := strconv.ParseUint(codeIDStr, 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?error=invalid_code")
return
}
invService := service.NewInvitationService(model.DB)
err = invService.RevokeInvitationCode(uint(codeID), user.ID)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?error=revoke_failed")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/employee/child/"+childIDStr+"/invite?success=revoked")
}
// GetInvitationInfo returns invitation details as JSON (for QR code / pre-fill validation)
func GetInvitationInfo(c *gin.Context) {
code := c.Param("code")
invService := service.NewInvitationService(model.DB)
invitation, err := invService.GetInvitationByCode(code)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invitation not found"})
return
}
if !invitation.IsValid() {
status := "invalid"
if invitation.IsUsed() {
status = "used"
} else if invitation.IsRevoked() {
status = "revoked"
} else if invitation.IsExpired() {
status = "expired"
}
c.JSON(http.StatusGone, gin.H{"error": "Invitation is no longer valid", "status": status})
return
}
c.JSON(http.StatusOK, gin.H{
"code": invitation.Code,
"childFirstName": invitation.Child.FirstName,
"childLastName": invitation.Child.LastName,
"childBirthday": invitation.Child.Birthday.Format("2006-01-02"),
"locationName": invitation.Child.Group.Location.Name,
"groupName": invitation.Child.Group.Name,
"relationshipRole": invitation.RelationshipRole,
"expiresAt": invitation.ExpiresAt.Format("2006-01-02"),
})
}
package controller
import (
"net/http"
"os"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// BulkChildData holds child info with group context
type BulkChildData struct {
Child model.Child
GroupName string
LocationName string
HasActiveCode bool // Has unused, non-expired invitation code
}
// BulkGroupData holds a group and its children for bulk selection
type BulkGroupData struct {
Group model.Group
Children []BulkChildData
}
// ShowBulkInvitationPage displays the child selection page for bulk QR code generation
// Requires GroupLead, LocationLead, or Admin role
func ShowBulkInvitationPage(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check permission - only GroupLead, LocationLead, or Admin
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
"user": user,
})
return
}
var groups []BulkGroupData
if user.IsAdmin() {
// Admin: check if location is selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
if hasAdminLoc && adminLocId.(int) > 0 {
// Admin with location selected - show children from that location
var dbGroups []model.Group
model.DB.Preload("Location").
Where("location_id = ?", adminLocId).
Order("name ASC").
Find(&dbGroups)
groups = buildBulkGroupData(dbGroups)
} else {
// Admin without location - show prompt to select location
util.RenderHTMLOK(c, "invitation-bulk-select.html", gin.H{
"title": "invitation.bulk.title",
"lang": langStr,
"user": user,
"needsLocation": true,
})
return
}
} else if user.IsHouseLeader() {
// LocationLead: show children from their locations
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"lang": langStr,
"user": user,
"error": "Could not determine your locations",
})
return
}
var dbGroups []model.Group
model.DB.Preload("Location").
Where("location_id IN ?", locationIDs).
Order("name ASC").
Find(&dbGroups)
groups = buildBulkGroupData(dbGroups)
} else if user.IsGroupLeader() {
// GroupLead: show children from their groups
// (handles intranet-linked employees)
dbGroups, _ := user.GetEmployeeCoreTeamGroups(model.DB)
groups = buildBulkGroupData(dbGroups)
}
relationshipRoles := service.GetAllRelationshipRoles()
util.RenderHTMLOK(c, "invitation-bulk-select.html", gin.H{
"title": "invitation.bulk.title",
"lang": langStr,
"user": user,
"groups": groups,
"relationshipRoles": relationshipRoles,
})
}
// buildBulkGroupData fetches children for each group and builds the display structure
func buildBulkGroupData(dbGroups []model.Group) []BulkGroupData {
var result []BulkGroupData
now := time.Now()
for _, group := range dbGroups {
var children []model.Child
model.DB.Where("group_id = ?", group.ID).
Order("first_name ASC").
Find(&children)
var childData []BulkChildData
for _, child := range children {
locationName := ""
if group.Location != nil {
locationName = group.Location.Name
}
// Check if child has any active (unused, non-revoked, non-expired) invitation codes
var activeCodeCount int64
model.DB.Model(&model.InvitationCode{}).
Where("child_id = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?", child.ID, now).
Count(&activeCodeCount)
childData = append(childData, BulkChildData{
Child: child,
GroupName: group.Name,
LocationName: locationName,
HasActiveCode: activeCodeCount > 0,
})
}
result = append(result, BulkGroupData{
Group: group,
Children: childData,
})
}
return result
}
// GenerateBulkInvitations handles the form submission to generate codes for selected children
func GenerateBulkInvitations(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check permission
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
// Parse form data
childIDStrs := c.PostFormArray("child_ids")
roleStrs := c.PostFormArray("roles")
if len(childIDStrs) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk?error=no_children")
return
}
if len(roleStrs) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk?error=no_roles")
return
}
// Convert child IDs
var childIDs []uint
for _, idStr := range childIDStrs {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
continue
}
childIDs = append(childIDs, uint(id))
}
// Convert roles
var roles []model.RelationshipRole
for _, roleStr := range roleStrs {
roles = append(roles, model.RelationshipRole(roleStr))
}
// Validate permissions for each child
invService := service.NewInvitationService(model.DB)
var validChildIDs []uint
for _, childID := range childIDs {
canGenerate, err := invService.CanUserGenerateInvitation(user.ID, childID)
if err == nil && canGenerate {
validChildIDs = append(validChildIDs, childID)
}
}
if len(validChildIDs) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk?error=no_permission")
return
}
// Generate invitations
invitations, err := invService.GenerateBulkInvitations(validChildIDs, roles, user.ID)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk?error=generation_failed")
return
}
// Collect invitation IDs for the print page
var invitationIDs []string
for _, inv := range invitations {
invitationIDs = append(invitationIDs, strconv.FormatUint(uint64(inv.ID), 10))
}
// Redirect to print page
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/print?codes="+strings.Join(invitationIDs, ","))
}
// BulkChildInvitations holds a child with their generated invitation codes
type BulkChildInvitations struct {
Child model.Child
GroupName string
Invitations []*model.InvitationCode
}
// ShowBulkPrintPage displays the print-optimized page with QR codes
func ShowBulkPrintPage(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check permission
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
"user": user,
})
return
}
// Parse invitation IDs from query
codesParam := c.Query("codes")
if codesParam == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk")
return
}
idStrs := strings.Split(codesParam, ",")
var ids []uint
for _, idStr := range idStrs {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
continue
}
ids = append(ids, uint(id))
}
if len(ids) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk")
return
}
// Fetch invitations
invService := service.NewInvitationService(model.DB)
invitations, err := invService.GetInvitationsByIDs(ids)
if err != nil || len(invitations) == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/invitations/bulk?error=codes_not_found")
return
}
// Group invitations by child
childMap := make(map[uint]*BulkChildInvitations)
for _, inv := range invitations {
if _, exists := childMap[inv.ChildID]; !exists {
groupName := ""
if inv.Child.Group != nil {
groupName = inv.Child.Group.Name
}
childMap[inv.ChildID] = &BulkChildInvitations{
Child: inv.Child,
GroupName: groupName,
Invitations: make([]*model.InvitationCode, 0),
}
}
childMap[inv.ChildID].Invitations = append(childMap[inv.ChildID].Invitations, inv)
}
// Convert map to slice
var childrenWithCodes []BulkChildInvitations
for _, ci := range childMap {
childrenWithCodes = append(childrenWithCodes, *ci)
}
// Build base URL for QR codes
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
fullBaseURL := scheme + "://" + baseURL
util.RenderHTMLOK(c, "invitation-bulk-print.html", gin.H{
"title": "invitation.bulk.print",
"lang": langStr,
"user": user,
"childrenWithCodes": childrenWithCodes,
"baseURL": fullBaseURL,
})
}
package controller
import (
"net/http"
"strings"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// ChangeLanguage handles language switching for authenticated users
func ChangeLanguage(c *gin.Context) {
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusSeeOther, "/login")
return
}
user := userInterface.(*model.User)
// Get requested language from form
requestedLang := c.PostForm("language")
// Validate language is supported
if !i18n.IsSupported(requestedLang) {
// Invalid language, redirect back without changing
referer := c.GetHeader("Referer")
if referer == "" {
referer = "/de/"
}
c.Redirect(http.StatusSeeOther, referer)
return
}
// Update user's language preference in database
if err := model.DB.Model(user).Update("language", requestedLang).Error; err != nil {
// Database error, redirect back without changing
referer := c.GetHeader("Referer")
if referer == "" {
referer = "/de/"
}
c.Redirect(http.StatusSeeOther, referer)
return
}
// Get the current path from referer and change the language prefix
referer := c.GetHeader("Referer")
newPath := buildLanguagePath(referer, requestedLang)
// Redirect to the same page in the new language
c.Redirect(http.StatusSeeOther, newPath)
}
// buildLanguagePath converts a path from one language to another
// Example: http://localhost:8080/de/home -> /en/home
func buildLanguagePath(referer, newLang string) string {
// Default redirect path
defaultPath := "/" + newLang + "/"
if referer == "" {
return defaultPath
}
// Extract path from referer URL
// Remove protocol and domain
parts := strings.SplitN(referer, "://", 2)
if len(parts) < 2 {
return defaultPath
}
// Remove domain, keep path
pathParts := strings.SplitN(parts[1], "/", 2)
if len(pathParts) < 2 {
return defaultPath
}
path := "/" + pathParts[1]
// Check if path starts with a language prefix
for _, lang := range i18n.SupportedLanguages() {
prefix := "/" + lang + "/"
if strings.HasPrefix(path, prefix) {
// Replace old language prefix with new one
remainingPath := strings.TrimPrefix(path, prefix)
return "/" + newLang + "/" + remainingPath
}
// Also handle case where path is just "/de" or "/en"
if path == "/"+lang || path == "/"+lang+"/" {
return "/" + newLang + "/"
}
}
// If no language prefix found, add it
if strings.HasPrefix(path, "/") {
path = strings.TrimPrefix(path, "/")
}
return "/" + newLang + "/" + path
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// getUserOrDummyLegal returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyLegal(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
// ShowImpressum displays the Impressum page (public, no auth required)
func ShowImpressum(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
// Get user if logged in (for navigation display)
user := getUserOrDummyLegal(c)
settings, err := model.GetLegalSettings()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
})
return
}
var content string
if settings != nil {
if langStr == "en" {
content = settings.ImpressumEN
} else {
content = settings.ImpressumDE
}
}
// Check if content is empty
isEmpty := content == ""
util.RenderHTMLOK(c, "legal-page.html", gin.H{
"lang": langStr,
"user": user,
"title": "Impressum",
"pageType": "impressum",
"content": util.RenderMarkdown(content),
"isEmpty": isEmpty,
})
}
// ShowDatenschutz displays the Privacy Policy page (public, no auth required)
func ShowDatenschutz(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
user := getUserOrDummyLegal(c)
settings, err := model.GetLegalSettings()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"lang": langStr,
"user": user,
})
return
}
var content string
if settings != nil {
if langStr == "en" {
content = settings.DatenschutzEN
} else {
content = settings.DatenschutzDE
}
}
isEmpty := content == ""
util.RenderHTMLOK(c, "legal-page.html", gin.H{
"lang": langStr,
"user": user,
"title": "Datenschutz",
"pageType": "datenschutz",
"content": util.RenderMarkdown(content),
"isEmpty": isEmpty,
})
}
package controller
// Location access times controller - allows LocationLeads to manage parent access validity periods
// Group leaders can extend access up to 6 months beyond contract dates
//
// [impl->dsn~zugriffsmanagement-design~1]
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// LocationAccessTimesList shows parent access times for children in the LocationLead's location(s)
// Uses search-first approach: only shows results when a child name is searched
func LocationAccessTimesList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only LocationLead (or Admin) can access this
if !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get LocationLead's locations
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// For Admin without specific locations, show error - they should use admin interface
if user.IsAdmin() && locationIDs == nil {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"lang": langStr,
"user": user,
"message": "Admins should use the admin interface for user management",
})
return
}
// Get locations for display
var locations []model.Location
if len(locationIDs) > 0 {
model.DB.Where("id IN ?", locationIDs).Find(&locations)
}
// Get search query - only load results if search is provided
searchQuery := c.Query("search")
var relationships []service.UserChildRelationshipInfo
searched := false
if searchQuery != "" {
searched = true
relationships, err = service.SearchUserChildRelationshipsByLocation(model.DB, locationIDs, searchQuery)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
}
util.RenderHTMLOK(c, "location-access-times-list.html", gin.H{
"lang": langStr,
"user": user,
"relationships": relationships,
"locations": locations,
"search": searchQuery,
"searched": searched,
"title": "Parent Access Times",
})
}
// LocationShowEditAccessTime shows the form to edit access time for a user-child relationship
func LocationShowEditAccessTime(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only LocationLead (or Admin) can access this
if !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("userid")
childIDStr := c.Param("childid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Scope check (audit #464): use CanUserAccessChild instead of a
// hand-rolled GetLocationIDs / group_id comparison loop. Same rule,
// one helper.
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
if !canAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Load relationship
userChild, err := service.GetUserChildRelationship(model.DB, uint(userID), uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Load parent user
parentUser, err := service.GetUserByID(model.DB, uint(userID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Format dates for form
var validFromStr, validUntilStr string
if userChild.ValidFrom != nil {
validFromStr = userChild.ValidFrom.Format("2006-01-02")
}
if userChild.ValidUntil != nil {
validUntilStr = userChild.ValidUntil.Format("2006-01-02")
}
util.RenderHTMLOK(c, "location-access-times-edit.html", gin.H{
"lang": langStr,
"user": user,
"parentUser": parentUser,
"userChild": userChild,
"validFromStr": validFromStr,
"validUntilStr": validUntilStr,
"title": "Edit Access Time",
})
}
// LocationUpdateAccessTime handles updating access time for a user-child relationship
func LocationUpdateAccessTime(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only LocationLead (or Admin) can access this
if !user.IsHouseLeader() && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get IDs from URL
userIDStr := c.Param("userid")
childIDStr := c.Param("childid")
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
childID, err := strconv.ParseUint(childIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Scope check (audit #464): same rule as LocationShowEditAccessTime
// — CanUserAccessChild handles admin-bypass + assigned-location
// match for staff in one helper.
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
if !canAccess {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Load relationship to read existing relationship role for update
userChild, err := service.GetUserChildRelationship(model.DB, uint(userID), uint(childID))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Parse form data - only update validity dates, not relationship role
validFromStr := c.PostForm("valid_from")
validUntilStr := c.PostForm("valid_until")
// Parse dates
var validFrom, validUntil *time.Time
if validFromStr != "" {
if parsed, err := time.Parse("2006-01-02", validFromStr); err == nil {
validFrom = &parsed
}
}
if validUntilStr != "" {
if parsed, err := time.Parse("2006-01-02", validUntilStr); err == nil {
validUntil = &parsed
}
}
// Update the relationship - keep the existing relationship role
err = service.UpdateUserChildRelationship(model.DB, uint(userID), uint(childID), userChild.RelationshipRole, validFrom, validUntil)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/location/access-times")
}
package controller
// Location settings controller - allows LocationLeads and Admins (with selected location)
// to manage location-specific settings such as employee access to all children at the location
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// LocationSettings shows the settings page for a location
// Accessible by LocationLead or Admin with selected location
func LocationSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Only LocationLead or Admin with selected location can access this
if !user.IsHouseLeader() && !isAdminWithLocation {
if user.IsAdmin() {
// Admin without location - prompt to select one
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "settings",
})
return
}
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID - either from admin selection or from LocationLead's assignment
var locationID uint
if isAdminWithLocation {
locationID = uint(adminLocId.(int))
} else {
// Get LocationLead's locations
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
locationID = locationIDs[0]
}
// Get the location
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Load groups for this location (for phone number configuration)
var groups []model.Group
model.DB.Where("location_id = ?", locationID).Order("name").Find(&groups)
util.RenderHTMLOK(c, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": groups,
"title": "Location Settings",
"currentYear": time.Now().Year(),
})
}
// LocationSettingsSave handles the form submission to save location settings
func LocationSettingsSave(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Only LocationLead or Admin with selected location can access this
if !user.IsHouseLeader() && !isAdminWithLocation {
if user.IsAdmin() {
// Admin without location - prompt to select one
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "settings",
})
return
}
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get location ID - either from admin selection or from LocationLead's assignment
var locationID uint
if isAdminWithLocation {
locationID = uint(adminLocId.(int))
} else {
// Get LocationLead's locations
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
locationID = locationIDs[0]
}
// Get the location
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Update settings from form
employeeLocationAccess := c.PostForm("employeeLocationAccess")
if employeeLocationAccess == "" {
employeeLocationAccess = string(model.EmployeeAccessLocationLeads)
}
location.EmployeeLocationAccess = model.EmployeeLocationAccess(employeeLocationAccess)
// Handle Bundesland
location.Bundesland = c.PostForm("bundesland")
// Handle calendar & reminder defaults
location.DefaultEventReminder = c.PostForm("defaultEventReminder") == "on"
// Handle absence notification cutoff time
cutoffTime := c.PostForm("absenceNotificationCutoff")
if cutoffTime == "" {
location.AbsenceNotificationCutoff = nil
} else {
// Validate HH:MM format
if _, err := time.Parse("15:04", cutoffTime); err != nil {
var errGroups []model.Group
model.DB.Where("location_id = ?", locationID).Order("name").Find(&errGroups)
util.RenderHTML(c, http.StatusBadRequest, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": errGroups,
"title": "Location Settings",
"error": "Invalid time format. Please use HH:MM format.",
"currentYear": time.Now().Year(),
})
return
}
location.AbsenceNotificationCutoff = &cutoffTime
}
// Handle archive retention settings
if newsRetention := c.PostForm("newsRetentionDays"); newsRetention != "" {
if days, err := strconv.Atoi(newsRetention); err == nil && days >= 1 && days <= 365 {
location.NewsRetentionDays = days
}
}
if letterRetention := c.PostForm("letterRetentionDays"); letterRetention != "" {
if days, err := strconv.Atoi(letterRetention); err == nil && days >= 1 && days <= 365 {
location.LetterRetentionDays = days
}
}
if messageRetention := c.PostForm("messageRetentionDays"); messageRetention != "" {
if days, err := strconv.Atoi(messageRetention); err == nil && days >= 1 && days <= 365 {
location.MessageRetentionDays = days
}
}
if err := model.DB.Save(&location).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
return
}
// Update group phone numbers
var groups []model.Group
model.DB.Where("location_id = ?", locationID).Order("name").Find(&groups)
for i := range groups {
phone := c.PostForm("group_phone_" + strconv.FormatUint(uint64(groups[i].ID), 10))
if groups[i].PhoneNumber != phone {
groups[i].PhoneNumber = phone
model.DB.Model(&groups[i]).Update("phone_number", phone)
}
}
// Redirect back to settings page with success message
util.RenderHTMLOK(c, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": groups,
"title": "Location Settings",
"success": true,
"currentYear": time.Now().Year(),
})
}
// LocationHolidayImport handles importing public holidays for a location
func LocationHolidayImport(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
if !user.IsHouseLeader() && !isAdminWithLocation {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
var locationID uint
if isAdminWithLocation {
locationID = uint(adminLocId.(int))
} else {
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
locationID = locationIDs[0]
}
var location model.Location
if err := model.DB.First(&location, locationID).Error; err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
if location.Bundesland == "" {
var groups []model.Group
model.DB.Where("location_id = ?", locationID).Order("name").Find(&groups)
util.RenderHTMLOK(c, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": groups,
"title": "Location Settings",
"importError": "bundesland_required",
"currentYear": time.Now().Year(),
})
return
}
yearStr := c.PostForm("year")
year, err := strconv.Atoi(yearStr)
if err != nil || year < 2020 || year > 2100 {
year = time.Now().Year()
}
result, err := service.ImportHolidaysForLocation(model.DB, locationID, location.Bundesland, year, user.ID)
var groups []model.Group
model.DB.Where("location_id = ?", locationID).Order("name").Find(&groups)
if err != nil {
util.RenderHTMLOK(c, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": groups,
"title": "Location Settings",
"importError": err.Error(),
"currentYear": time.Now().Year(),
})
return
}
util.RenderHTMLOK(c, "location-settings.html", gin.H{
"lang": langStr,
"user": user,
"location": location,
"groups": groups,
"title": "Location Settings",
"importResult": result,
"importYear": year,
"currentYear": time.Now().Year(),
})
}
package controller
// News controller - handles news/announcements for groups and locations
// Group leaders and admins can create news, parents/employees view them
//
// [impl->dsn~gui-ankuendigungen~1]
import (
"io"
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/storage"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// News routes to appropriate list view based on user role
func News(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{"lang": langStr})
}
return
}
user := userInterface.(*model.User)
// Get active role for view selection (supports dual-role users)
activeRole, _ := c.Get("activeRole")
activeRoleStr, _ := activeRole.(string)
// Route to appropriate view based on active role
showEmployeeView := activeRoleStr == "Employee" || activeRoleStr == "GroupLead" || activeRoleStr == "LocationLead"
if showEmployeeView && user.IsEmployee() {
ListNewsEmployee(c)
} else if user.IsParent() {
ListNewsParent(c)
} else if user.IsAdmin() {
// Admin users can access employee view when they have a location selected
if adminLocId, exists := c.Get("adminLocationId"); exists && adminLocId.(int) > 0 {
ListNewsEmployee(c)
} else {
// Admin without location selected - show message to select a location
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "news",
})
}
} else {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
}
}
// ListNewsParent shows published news for parent's children
func ListNewsParent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get parent's children with valid relationships
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get children"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// If parent has no valid children, show empty news list with only global news
if len(validChildren) == 0 {
var newsList []model.News
err := model.DB.Where("deleted_at IS NULL AND location_id IS NULL AND employee_only = ?", false).
Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check which news have been read
type NewsWithRead struct {
News model.News
Read bool
}
var newsWithRead []NewsWithRead
for _, news := range newsList {
var readRecord model.NewsRead
err := model.DB.Where("news_id = ? AND user_id = ?", news.ID, user.ID).First(&readRecord).Error
isRead := err == nil && readRecord.ReadAt != nil
newsWithRead = append(newsWithRead, NewsWithRead{
News: news,
Read: isRead,
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"news": newsWithRead,
})
} else {
util.RenderHTMLOK(c, "news-list-parent.html", gin.H{
"lang": langStr,
"user": user,
"news": newsWithRead,
"title": "News",
})
}
return
}
// Collect group IDs and location IDs from valid children
groupIDsMap := make(map[uint]bool)
locationIDsMap := make(map[uint]bool)
for _, child := range validChildren {
if child.Group != nil && child.GroupId != nil {
groupIDsMap[*child.GroupId] = true
locationIDsMap[child.Group.LocationId] = true
}
}
var groupIDs []uint
for id := range groupIDsMap {
groupIDs = append(groupIDs, id)
}
var locationIDs []uint
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
// Get all published news for parent's children's groups, location-wide news, or global news
var newsList []model.News
if len(groupIDs) > 0 {
err := model.DB.Where(
"deleted_at IS NULL AND employee_only = ? AND (group_id IN ? OR (group_id IS NULL AND location_id IN ?) OR location_id IS NULL)",
false,
groupIDs,
locationIDs,
).Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
} else {
// Parent has no children in groups, but still show global news
err := model.DB.Where("deleted_at IS NULL AND location_id IS NULL AND employee_only = ?", false).
Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Check which news have been read
type NewsWithRead struct {
News model.News
Read bool
}
var newsWithRead []NewsWithRead
for _, news := range newsList {
var readRecord model.NewsRead
err := model.DB.Where("news_id = ? AND user_id = ?", news.ID, user.ID).First(&readRecord).Error
isRead := err == nil && readRecord.ReadAt != nil
newsWithRead = append(newsWithRead, NewsWithRead{
News: news,
Read: isRead,
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"news": newsWithRead,
})
} else {
util.RenderHTMLOK(c, "news-list-parent.html", gin.H{
"lang": langStr,
"user": user,
"news": newsWithRead,
"title": "News",
})
}
}
// ListNewsEmployee shows news for employees and group leaders
func ListNewsEmployee(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Allow employees OR admins with a selected location
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
if !user.IsEmployee() && !isAdminWithLocation {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get user's assigned location IDs
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get locations"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get all news for user's locations
var newsList []model.News
if isAdminWithLocation {
// Admin with selected location sees news for that location OR global news
model.DB.Where("deleted_at IS NULL AND (location_id = ? OR location_id IS NULL)", adminLocId).
Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList)
} else if user.IsAdmin() {
// Admin without location selected sees all news
model.DB.Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList)
} else if len(locationIDs) > 0 {
// Employee/GroupLead/LocationLead sees news in their locations OR global news
model.DB.Where("deleted_at IS NULL AND (location_id IN ? OR location_id IS NULL)", locationIDs).
Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList)
} else {
// Staff with no assigned locations still sees global news
model.DB.Where("deleted_at IS NULL AND location_id IS NULL AND employee_only = ?", false).
Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&newsList)
}
// Add permission information for each news item
type NewsWithPermissions struct {
News model.News
CanEdit bool
}
var newsWithPermissions []NewsWithPermissions
for _, news := range newsList {
newsWithPermissions = append(newsWithPermissions, NewsWithPermissions{
News: news,
CanEdit: service.CanUserEditNews(model.DB, user, &news),
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"news": newsWithPermissions,
})
} else {
util.RenderHTMLOK(c, "news-list-employee.html", gin.H{
"lang": langStr,
"user": user,
"news": newsWithPermissions,
"title": "News",
"isAdminWithLocation": isAdminWithLocation,
})
}
}
// GetNewsDetail shows a single news entry in detail
func GetNewsDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid news ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load news
var news model.News
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").Preload("CreatedAsStandInFor").First(&news, newsID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Employee-only news must not leak to parents via the detail URL.
// Render as 404 (rather than 403) so we don't disclose existence.
if news.EmployeeOnly && !user.IsEmployee() && !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check: a parent can only see news targeted at their child's
// group or location, or global news. Without this check, any parent
// could GET /de/news/<any-id> for any other location's news — the
// list view filters correctly but the detail endpoint did not.
// Audit finding #464 ("GetNewsDetail lacks scope check for parents").
// service.CanUserViewNews is the canonical helper (already used by
// ServeNewsAttachment).
if !service.CanUserViewNews(model.DB, user, &news) {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark as read for this user
service.MarkNewsAsRead(model.DB, uint(newsID), user.ID)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Get read statistics if user is employee or admin with location.
// Skip for employee-only news: GetNewsRecipients returns parents (the
// normal audience for news), which would render a misleading
// "not read by these parents" list on an entry parents aren't even
// supposed to see.
var read []model.User
var unread []model.User
if !news.EmployeeOnly && (user.IsEmployee() || isAdminWithLocation) {
read, unread, _ = service.GetNewsReadStatistics(model.DB, uint(newsID))
}
// Format text using Markdown
htmlContent := util.RenderMarkdown(news.Text)
// Check if user can edit this news
canEdit := service.CanUserEditNews(model.DB, user, &news)
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByNews(uint(newsID))
// Calculate read percentage for global news
readPercent := 0
totalCount := len(read) + len(unread)
if totalCount > 0 {
readPercent = (len(read) * 100) / totalCount
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"news": news,
"html": string(htmlContent),
"read": read,
"unread": unread,
"canEdit": canEdit,
"readPercent": readPercent,
"totalCount": totalCount,
"attachments": attachments,
})
} else {
// Choose template based on user role (admin with location sees employee view)
templateName := "news-detail.html"
if user.IsEmployee() || isAdminWithLocation {
templateName = "news-detail-employee.html"
}
util.RenderHTMLOK(c, templateName, gin.H{
"lang": langStr,
"user": user,
"news": news,
"htmlContent": htmlContent,
"read": read,
"unread": unread,
"title": news.Title,
"canEdit": canEdit,
"readPercent": readPercent,
"totalCount": totalCount,
"attachments": attachments,
})
}
}
// ShowCreateNews shows the form for creating new news
func ShowCreateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
scopes, _ := service.AuthorableScopesForUser(model.DB, user)
// Authorize: must be a lead OR hold at least one active stand-in.
if !user.IsGroupLeader() && !user.IsHouseLeader() && len(scopes.StandInLeads) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
util.RenderHTMLOK(c, "news-create.html", gin.H{
"lang": langStr,
"user": user,
"groups": scopes.Groups,
"standInLeads": scopes.StandInLeads,
"locationWideAllowed": scopes.LocationWideAllowed,
"title": "Create News",
"maxAttachments": storage.MaxAttachmentsCount,
})
}
// CreateNews handles the creation of new news
func CreateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Parse form data
title := c.PostForm("title")
text := c.PostForm("text")
validUntilStr := c.PostForm("valid_until")
locationWide := c.PostForm("location_wide") == "true"
groupIDStrs := c.PostFormArray("group_ids")
employeeOnly := c.PostForm("employee_only") == "true"
// Validate required fields
if title == "" || text == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "title and text required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/create?error=missing_fields")
}
return
}
// Parse valid until date
var validUntil *time.Time
if validUntilStr != "" {
parsedTime, err := time.Parse("2006-01-02", validUntilStr)
if err == nil {
validUntil = &parsedTime
}
}
// Parse group IDs from multi-select
var groupIDs []uint
if !locationWide && len(groupIDStrs) > 0 {
for _, idStr := range groupIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid group ID"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/create?error=invalid_group")
}
return
}
groupIDs = append(groupIDs, uint(parsed))
}
}
// Validate: need either location-wide or at least one group
if !locationWide && len(groupIDs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "select at least one group or choose location-wide"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/create?error=no_scope")
}
return
}
// Determine location ID
var locationId uint
if len(groupIDs) > 0 {
var group model.Group
if err := model.DB.First(&group, groupIDs[0]).Error; err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "group not found"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/create?error=group_not_found")
}
return
}
locationId = group.LocationId
} else {
groups, _ := user.GetEmployeeCoreTeamGroups(model.DB)
if len(groups) == 0 {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "no location access"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news?error=no_location")
}
return
}
locationId = groups[0].LocationId
}
// Parse AJAX-uploaded attachment IDs
attachmentIDStrs := c.PostFormArray("attachment_ids")
var attachmentIDs []uint
for _, idStr := range attachmentIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
attachmentIDs = append(attachmentIDs, uint(parsed))
}
}
if locationWide {
// Single location-wide news (GroupId = nil)
ok, standInFor := service.AuthorizeCreateNews(model.DB, user, nil, &locationId)
if !ok {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
var standInForID *uint
if standInFor != nil {
id := standInFor.ID
standInForID = &id
}
news := model.News{
Title: title,
Text: text,
CreatedById: user.ID,
PublishedAt: time.Now(),
ValidUntil: validUntil,
LocationId: &locationId,
GroupId: nil,
EmployeeOnly: employeeOnly,
CreatedAsStandInForID: standInForID,
}
if err := model.DB.Create(&news).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create news"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if len(attachmentIDs) > 0 {
fileStorage := storage.NewDBStorage(model.DB)
fileStorage.AssociateOrphansToNews(attachmentIDs, news.ID)
}
service.NotifyNewsPublished(model.DB, &news)
} else {
// Create one news per selected group
for i, gid := range groupIDs {
groupID := gid
ok, standInFor := service.AuthorizeCreateNews(model.DB, user, &groupID, &locationId)
if !ok {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied for group"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
var standInForID *uint
if standInFor != nil {
id := standInFor.ID
standInForID = &id
}
news := model.News{
Title: title,
Text: text,
CreatedById: user.ID,
PublishedAt: time.Now(),
ValidUntil: validUntil,
LocationId: &locationId,
GroupId: &groupID,
EmployeeOnly: employeeOnly,
CreatedAsStandInForID: standInForID,
}
if err := model.DB.Create(&news).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create news"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Attach uploads only to the first news record
if i == 0 && len(attachmentIDs) > 0 {
fileStorage := storage.NewDBStorage(model.DB)
fileStorage.AssociateOrphansToNews(attachmentIDs, news.ID)
}
service.NotifyNewsPublished(model.DB, &news)
}
}
if isJSON {
c.JSON(http.StatusCreated, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news")
}
}
// ShowEditNews shows the form for editing existing news
func ShowEditNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load news
var news model.News
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").Preload("CreatedAsStandInFor").First(&news, newsID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check if user can edit
if !service.CanUserEditNews(model.DB, user, &news) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Get user's groups based on their role
var groups []model.Group
if user.IsHouseLeader() {
// LocationLeads can send news to ALL groups in their location(s)
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
model.DB.Where("location_id IN ?", locationIDs).
Preload("Location").
Find(&groups)
}
} else {
// GroupLeads can only send news to their directly assigned groups
// (handles intranet-linked employees)
groups, _ = user.GetEmployeeCoreTeamGroups(model.DB)
}
// Extract pointer values for template
var selectedGroupId uint
if news.GroupId != nil {
selectedGroupId = *news.GroupId
}
var validUntilStr string
if news.ValidUntil != nil {
validUntilStr = news.ValidUntil.Format("2006-01-02")
}
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByNews(uint(newsID))
util.RenderHTMLOK(c, "news-edit.html", gin.H{
"lang": langStr,
"user": user,
"news": news,
"groups": groups,
"selectedGroupId": selectedGroupId,
"validUntilStr": validUntilStr,
"title": "Edit News",
"attachments": attachments,
"maxAttachments": storage.MaxAttachmentsCount,
})
}
// UpdateNews handles the update of existing news
func UpdateNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid news ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load news
var news model.News
err = model.DB.First(&news, newsID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if user can edit
if !service.CanUserEditNews(model.DB, user, &news) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form data
title := c.PostForm("title")
text := c.PostForm("text")
validUntilStr := c.PostForm("valid_until")
// Validate required fields
if title == "" || text == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "title and text required"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/"+newsIDStr+"/edit?error=missing_fields")
}
return
}
// Parse valid until date
var validUntil *time.Time
if validUntilStr != "" {
parsedTime, err := time.Parse("2006-01-02", validUntilStr)
if err == nil {
validUntil = &parsedTime
}
}
// Update news
news.Title = title
news.Text = text
news.ValidUntil = validUntil
if err := model.DB.Save(&news).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update news"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Associate any new AJAX-uploaded orphan attachments
attachmentIDStrs := c.PostFormArray("attachment_ids")
if len(attachmentIDStrs) > 0 {
var attachmentIDs []uint
for _, idStr := range attachmentIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
attachmentIDs = append(attachmentIDs, uint(parsed))
}
}
if len(attachmentIDs) > 0 {
fileStorage := storage.NewDBStorage(model.DB)
fileStorage.AssociateOrphansToNews(attachmentIDs, news.ID)
}
}
// Redirect to news detail
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"news": news,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news/"+newsIDStr)
}
}
// DeleteNews handles the deletion of news
func DeleteNews(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get news ID from URL
newsIDStr := c.Param("id")
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid news ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load news
var news model.News
err = model.DB.First(&news, newsID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if user can delete
if !service.CanUserDeleteNews(model.DB, user, &news) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "permission denied"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Delete associated attachments before soft-deleting news
model.DB.Where("news_id = ?", news.ID).Delete(&model.Attachment{})
// Soft delete news
if err := model.DB.Delete(&news).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to delete news"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Redirect to news list
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/news")
}
}
// UploadNewsAttachment handles AJAX file upload for news entries
func UploadNewsAttachment(c *gin.Context) {
userInterface, ok := c.Get("User")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
return
}
user := userInterface.(*model.User)
// Only GroupLeaders, LocationLeaders, and Admins can upload attachments
if !user.IsGroupLeader() && !user.IsHouseLeader() && !user.IsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
// Get optional news_id (for editing existing news)
newsIDStr := c.PostForm("news_id")
var newsID *uint
if newsIDStr != "" {
parsed, err := strconv.ParseUint(newsIDStr, 10, 32)
if err == nil {
uid := uint(parsed)
newsID = &uid
// Verify user can edit this news
var news model.News
if err := model.DB.First(&news, uid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "news not found"})
return
}
if !service.CanUserEditNews(model.DB, user, &news) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
}
}
// Get uploaded file
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "no file uploaded"})
return
}
if fileHeader.Size == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "empty file"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
return
}
// Validate file
mimeType, err := storage.ValidateFile(fileHeader.Filename, content)
if err != nil {
errorMsg := "invalid file"
switch err {
case storage.ErrFileTooLarge:
errorMsg = "file too large (max 10 MB)"
case storage.ErrInvalidFileType:
errorMsg = "invalid file type (only PDF, PNG, JPG, GIF allowed)"
}
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
return
}
fileStorage := storage.NewDBStorage(model.DB)
var attachmentID uint
if newsID != nil {
attachmentID, err = fileStorage.StoreForNews(*newsID, fileHeader.Filename, mimeType, content)
} else {
attachmentID, err = fileStorage.StoreOrphan(user.ID, fileHeader.Filename, mimeType, content)
}
if err != nil {
errorMsg := "failed to store file"
switch err {
case storage.ErrTooManyFiles:
errorMsg = "too many attachments (max 5)"
case storage.ErrTotalSizeExceeded:
errorMsg = "total attachment size exceeded (max 30 MB)"
}
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"attachment": gin.H{
"id": attachmentID,
"filename": fileHeader.Filename,
"mimeType": mimeType,
"size": fileHeader.Size,
},
})
}
// ServeNewsAttachment serves a news attachment file
func ServeNewsAttachment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
attachmentIDStr := c.Param("id")
attachmentID, err := strconv.ParseUint(attachmentIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid attachment ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load attachment with content
var attachment model.Attachment
err = model.DB.First(&attachment, attachmentID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "attachment not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check access
hasAccess := false
if attachment.NewsId == nil {
// Orphan attachment — only uploader can access
hasAccess = attachment.UploadedById != nil && *attachment.UploadedById == user.ID
} else {
// Load the associated news entry
var news model.News
if err := model.DB.First(&news, *attachment.NewsId).Error; err == nil {
hasAccess = service.CanUserViewNews(model.DB, user, &news)
}
}
if !hasAccess {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
c.Header("Content-Type", attachment.MimeType)
c.Header("Content-Disposition", "attachment; filename=\""+attachment.Filename+"\"")
c.Header("Content-Length", strconv.FormatInt(attachment.Size, 10))
c.Data(http.StatusOK, attachment.MimeType, attachment.Content)
}
// DeleteNewsAttachment removes a news attachment
func DeleteNewsAttachment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
attachmentIDStr := c.Param("id")
attachmentID, err := strconv.ParseUint(attachmentIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid attachment ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load attachment metadata
fileStorage := storage.NewDBStorage(model.DB)
attachment, err := fileStorage.GetAttachment(uint(attachmentID))
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "attachment not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
canDelete := false
if attachment.NewsId == nil {
// Orphan — only uploader can delete
canDelete = attachment.UploadedById != nil && *attachment.UploadedById == user.ID
} else {
// Associated with news — check edit permission
var news model.News
if err := model.DB.First(&news, *attachment.NewsId).Error; err == nil {
canDelete = service.CanUserEditNews(model.DB, user, &news)
}
}
if !canDelete {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
if err := fileStorage.Delete(uint(attachmentID)); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to delete attachment"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "attachment deleted",
})
}
package controller
// Notify controller handles absence notifications from parents
// Parents can report their child as sick, on vacation, or otherwise absent
//
// [impl->dsn~abwesenheitsmeldungen-design~1]
import (
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
type groupPhone struct {
GroupName string
Phone string
}
func collectGroupPhones(children []*model.Child) []groupPhone {
seen := map[uint]bool{}
var result []groupPhone
for _, c := range children {
if c.Group != nil && c.Group.PhoneNumber != "" && !seen[c.Group.ID] {
seen[c.Group.ID] = true
result = append(result, groupPhone{GroupName: c.Group.Name, Phone: c.Group.PhoneNumber})
}
}
return result
}
// getUserOrDummy returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyNotify(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
func Notify(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
// Fetch only children with valid enrollment dates
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
logger.Error("failed to get valid children", "userId", user.ID, "error", err)
validChildren = []*model.Child{}
}
user.Children = validChildren
// TODO: fetch previous notifications, max 10.
if isJSON {
// Return JSON response
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"user": user,
"children": user.Children,
},
})
return
}
// Return HTML response - user object now has both roles and children loaded
util.RenderHTMLOK(
c,
"notify.html",
gin.H{
"title": "Notify Absence",
"user": user,
"selectedChildID": uint(0), // No pre-selection when accessing /notify directly
"lang": langStr,
"groupPhones": collectGroupPhones(validChildren),
},
)
}
func NotifyDirect(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
id := c.Param("id")
// Parse and validate child ID
childID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid child ID format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "404.html", gin.H{
"title": "Wippidu - invalid child ID",
"lang": langStr,
"user": user,
})
}
return
}
// SECURITY FIX: Authorization check
// Verify user has permission to access this child
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"title": "Wippidu - server error",
"lang": langStr,
"user": user,
})
}
return
}
if !canAccess {
// Return 403 Forbidden for authorization failure
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "You can only create notifications for your own children",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "You can only create notifications for your own children.",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch only children with valid enrollment dates
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
logger.Error("failed to get valid children", "userId", user.ID, "error", err)
validChildren = []*model.Child{}
}
user.Children = validChildren
// TODO: fetch previous notifications, max 10.
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"selectedChildID": uint(childID),
"children": user.Children,
},
})
} else {
// Return HTML response - user object has both roles and children loaded
util.RenderHTMLOK(
c,
"notify.html",
gin.H{
"title": "Notify Absence",
"user": user,
"selectedChildID": uint(childID), // Pre-select this child in the grid
"lang": langStr,
"groupPhones": collectGroupPhones(validChildren),
},
)
}
}
// NotifySend handles the submission of absence notifications
func NotifySend(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
// Parse form data
childIDs := c.PostFormArray("child_ids[]")
fromDateStr := c.PostForm("from")
toDateStr := c.PostForm("to")
absenceType := c.PostForm("absence_type")
message := c.PostForm("message")
// Validate required fields
if len(childIDs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "At least one child must be selected",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "At least one child must be selected",
"lang": langStr,
})
}
return
}
// Parse dates
fromDate, err := time.Parse("2006-01-02", fromDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid 'from' date format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "Invalid 'from' date format",
"lang": langStr,
})
}
return
}
toDate, err := time.Parse("2006-01-02", toDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid 'to' date format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "Invalid 'to' date format",
"lang": langStr,
})
}
return
}
// Validate date range
if toDate.Before(fromDate) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "'To' date must be after 'from' date",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "'To' date must be after 'from' date",
"lang": langStr,
})
}
return
}
// Validate absence type
validTypes := map[string]bool{
"Vacation": true,
"Illness": true,
"Other": true,
}
if !validTypes[absenceType] {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid absence type",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "Invalid absence type",
"lang": langStr,
})
}
return
}
// Convert child IDs to uints and verify authorization
var childIDsUint []uint
for _, idStr := range childIDs {
childID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid child ID format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": "Invalid child ID format",
"lang": langStr,
})
}
return
}
// Authorization check: Verify user owns this child
canAccess, err := model.CanUserAccessChild(model.DB, user.ID, uint(childID))
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Internal server error",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"title": "Wippidu - server error",
"lang": langStr,
"user": user,
})
}
return
}
if !canAccess {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You can only create notifications for your own children",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "You can only create notifications for your own children.",
"lang": langStr,
"user": user,
})
}
return
}
childIDsUint = append(childIDsUint, uint(childID))
}
// Handle optional message
var messagePtr *string
if message != "" {
messagePtr = &message
}
// Check same-day notification cutoff time
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if fromDate.Equal(today) && len(childIDsUint) > 0 {
// This is a same-day notification - check cutoff time
// Get location from first child
var child model.Child
if err := model.DB.Preload("Group.Location").First(&child, childIDsUint[0]).Error; err == nil {
if child.Group != nil && child.Group.Location != nil {
location := child.Group.Location
if location.AbsenceNotificationCutoff != nil && *location.AbsenceNotificationCutoff != "" {
cutoffTime, err := time.Parse("15:04", *location.AbsenceNotificationCutoff)
if err == nil {
cutoff := time.Date(now.Year(), now.Month(), now.Day(),
cutoffTime.Hour(), cutoffTime.Minute(), 0, 0, now.Location())
if now.After(cutoff) {
// Return error with message to call daycare
errorMsg := i18n.TranslateWithData(langStr, "notify.error.samedaycutoff", map[string]interface{}{
"Time": *location.AbsenceNotificationCutoff,
})
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "SAME_DAY_CUTOFF",
"message": errorMsg,
},
})
} else {
// Fetch children for the user so the form renders properly
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
logger.Error("failed to get valid children", "userId", user.ID, "error", err)
validChildren = []*model.Child{}
}
user.Children = validChildren
util.RenderHTML(c, http.StatusBadRequest, "notify.html", gin.H{
"title": "Notify Absence",
"user": user,
"error": errorMsg,
"lang": langStr,
"groupPhones": collectGroupPhones(validChildren),
})
}
return
}
}
}
}
}
}
// Always extend ToDate to end of day (23:59:59) to ensure the last day
// of an absence is fully included in all date range queries
toDate = time.Date(toDate.Year(), toDate.Month(), toDate.Day(),
23, 59, 59, 999999999, toDate.Location())
// Check for existing overlapping notifications for each child
var childrenWithExisting []string
for _, childID := range childIDsUint {
var existing model.AbsenceNotification
// Check if any notification exists that overlaps with the requested date range
// Overlap condition: existing_start <= new_end AND existing_end >= new_start
err := model.DB.Where(
"child_id = ? AND deleted_at IS NULL AND from_date <= ? AND to_date >= ?",
childID,
toDate,
fromDate,
).First(&existing).Error
if err == nil {
// Found overlapping notification - get child name
var child model.Child
if err := model.DB.First(&child, childID).Error; err == nil {
childrenWithExisting = append(childrenWithExisting, child.FirstName)
}
}
}
if len(childrenWithExisting) > 0 {
// Return error - notification already exists
if isJSON {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": gin.H{
"code": "DUPLICATE_NOTIFICATION",
"message": "Notification already exists for this period",
"children": childrenWithExisting,
},
})
} else {
// Re-render form with error
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
logger.Error("failed to get valid children", "userId", user.ID, "error", err)
validChildren = []*model.Child{}
}
user.Children = validChildren
util.RenderHTML(c, http.StatusConflict, "notify.html", gin.H{
"title": "notify.title",
"lang": langStr,
"user": user,
"error": "notify.error.duplicate",
"errorChildren": childrenWithExisting,
"form": gin.H{
"from_date": fromDateStr,
"to_date": toDateStr,
"absence_type": absenceType,
"message": message,
"child_ids": childIDs,
},
})
}
return
}
// Create absence notifications for each selected child
var createdNotifications []model.AbsenceNotification
for _, childID := range childIDsUint {
notification := model.AbsenceNotification{
ChildId: childID,
UserId: user.ID,
FromDate: fromDate,
ToDate: toDate,
AbsenceType: absenceType,
Message: messagePtr,
}
if err := model.DB.Create(¬ification).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Failed to create notification",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"title": "Wippidu - server error",
"lang": langStr,
"user": user,
})
}
return
}
createdNotifications = append(createdNotifications, notification)
}
// Success response
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": gin.H{
"message": "Absence notifications created successfully",
"notifications": createdNotifications,
},
})
} else {
// Redirect to home page with success message
c.Redirect(http.StatusSeeOther, "/")
}
}
// NotifyHistory displays the last 25 notifications for the current user
func NotifyHistory(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
// Fetch last 25 notifications including soft-deleted ones
var notifications []model.AbsenceNotification
err := model.DB.Unscoped().
Preload("Child.Group.Location").
Where("user_id = ?", user.ID).
Order("created_at DESC").
Limit(25).
Find(¬ifications).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Failed to fetch notifications",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{
"title": "Wippidu - server error",
"lang": langStr,
"user": user,
})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"notifications": notifications,
},
})
} else {
util.RenderHTMLOK(c, "notify-history.html", gin.H{
"title": "Absence History",
"user": user,
"notifications": notifications,
"now": time.Now(),
"lang": langStr,
})
}
}
// NotifyEdit displays the edit form for a notification
func NotifyEdit(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
id := c.Param("id")
// Parse notification ID
notificationID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid notification ID format",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "404.html", gin.H{
"title": "Wippidu - invalid notification ID",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the notification
var notification model.AbsenceNotification
err = model.DB.Preload("Child.Group.Location").
Where("id = ? AND user_id = ?", notificationID, user.ID).
First(¬ification).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Notification not found or access denied",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - notification not found",
"lang": langStr,
"user": user,
})
}
return
}
// Check if notification can still be edited (ToDate must not be in the past)
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if notification.ToDate.Before(startOfToday) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Cannot edit past notifications",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - access denied",
"message": "You cannot edit notifications that have already ended.",
"lang": langStr,
"user": user,
})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"notification": notification,
},
})
} else {
util.RenderHTMLOK(c, "notify-edit.html", gin.H{
"title": "Edit Notification",
"user": user,
"notification": notification,
"lang": langStr,
"now": now,
})
}
}
// NotifyUpdate handles the update of a notification
func NotifyUpdate(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{
"title": "Wippidu - unauthorized",
"lang": langStr,
"user": getUserOrDummyNotify(c),
})
}
return
}
user := userInterface.(*model.User)
id := c.Param("id")
// Parse notification ID
notificationID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid notification ID format",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Fetch the notification to verify ownership
var notification model.AbsenceNotification
err = model.DB.Where("id = ? AND user_id = ?", notificationID, user.ID).
First(¬ification).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Notification not found or access denied",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Check if notification can still be edited (ToDate must not be in the past)
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if notification.ToDate.Before(startOfToday) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Cannot edit past notifications",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Parse form data
fromDateStr := c.PostForm("from")
toDateStr := c.PostForm("to")
absenceType := c.PostForm("absence_type")
message := c.PostForm("message")
// Parse dates
fromDate, err := time.Parse("2006-01-02", fromDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid 'from' date format",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
toDate, err := time.Parse("2006-01-02", toDateStr)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid 'to' date format",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// Validate date range
if toDate.Before(fromDate) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "'To' date must be after 'from' date",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// Additional validations for active absences
// Cannot change FromDate once the notification has started
if notification.FromDate.Before(now) && !fromDate.Equal(notification.FromDate) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Cannot change start date of active notification",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// ToDate cannot be before today (prevents retroactive shortening)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if toDate.Before(today) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "End date cannot be before today",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// Validate absence type
validTypes := map[string]bool{
"Vacation": true,
"Illness": true,
"Other": true,
}
if !validTypes[absenceType] {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid absence type",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// Handle optional message
var messagePtr *string
if message != "" {
messagePtr = &message
}
// Always extend ToDate to end of day (23:59:59) to ensure the last day
// of an absence is fully included in all date range queries
toDate = time.Date(toDate.Year(), toDate.Month(), toDate.Day(),
23, 59, 59, 999999999, toDate.Location())
// Update the notification
updates := map[string]interface{}{
"from_date": fromDate,
"to_date": toDate,
"absence_type": absenceType,
"message": messagePtr,
}
err = model.DB.Model(¬ification).Updates(updates).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Failed to update notification",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-edit/"+id)
}
return
}
// Success response
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": "Notification updated successfully",
"notification": notification,
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
}
// NotifyRevoke soft-deletes a notification
func NotifyRevoke(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "User not found in context",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
user := userInterface.(*model.User)
id := c.Param("id")
// Parse notification ID
notificationID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_ID",
"message": "Invalid notification ID format",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Fetch the notification to verify ownership
var notification model.AbsenceNotification
err = model.DB.Where("id = ? AND user_id = ?", notificationID, user.ID).
First(¬ification).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Notification not found or access denied",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Check if notification can still be revoked (FromDate must be in the future)
now := time.Now()
if notification.FromDate.Before(now) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Cannot revoke past notifications",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Soft delete the notification
err = model.DB.Delete(¬ification).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "SERVER_ERROR",
"message": "Failed to revoke notification",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
return
}
// Success response
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": "Notification revoked successfully",
},
})
} else {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/notify-history")
}
}
package controller
// Parent messages controller - handles direct messages from employees to parents
// Employees can compose messages, parents receive and can respond
//
// [impl->dsn~nachrichten-design~1]
import (
"net/http"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// getUserOrDummyMessages returns the authenticated user or a dummy user if not authenticated
func getUserOrDummyMessages(c *gin.Context) *model.User {
if userInterface, ok := c.Get("User"); ok {
if user, ok := userInterface.(*model.User); ok {
return user
}
}
return model.NewDummyUser()
}
// ParentMessages displays a list of all messages - routes to employee or parent view based on role
func ParentMessages(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Route to appropriate view based on user role
if user.IsParent() && !user.IsEmployee() {
// Parents (non-employees) see messages they've received
ParentViewReceivedMessages(c)
return
}
// Allow employees OR admins with a selected location
if !user.IsEmployee() && !isAdminWithLocation {
if user.IsAdmin() {
// Admin without location selected - show message to select a location
util.RenderHTMLOK(c, "admin-select-location.html", gin.H{
"lang": langStr,
"user": user,
"title": "Select Location",
"context": "messages",
})
return
}
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get tab parameter (own, group, location) - default to "own"
activeTab := c.Query("tab")
if activeTab == "" {
activeTab = "own"
}
// Validate tab parameter
if activeTab != "own" && activeTab != "group" && activeTab != "location" {
activeTab = "own"
}
// Check if user can view location messages
canViewLocationMessages := service.CanViewLocationMessages(model.DB, user)
// If user requested location tab but doesn't have access, fall back to own
if activeTab == "location" && !canViewLocationMessages {
activeTab = "own"
}
// Get filter parameter (child ID)
childIDParam := c.Query("child_id")
var childID uint
if childIDParam != "" {
childIDUint64, err := strconv.ParseUint(childIDParam, 10, 32)
if err == nil {
childID = uint(childIDUint64)
}
}
// Get user's assigned groups (handles intranet-linked employees)
userGroups, _ := user.GetEmployeeGroups(model.DB)
var userGroupIDs []uint
for _, g := range userGroups {
userGroupIDs = append(userGroupIDs, g.ID)
}
// Get groups and children based on user role
var groups []model.Group
var groupIDs []uint
var groupChildren []model.Child
if isAdminWithLocation {
// Admin with selected location sees all groups in that location
model.DB.Where("location_id = ?", adminLocId).Find(&groups)
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
} else {
// Regular employee sees their assigned groups
groups = userGroups
groupIDs = userGroupIDs
}
// Fetch all children in the groups (for filter dropdown)
if len(groupIDs) > 0 {
model.DB.Where("group_id IN (?)", groupIDs).
Preload("Group").
Order("first_name ASC").
Find(&groupChildren)
}
// Build query based on active tab
query := model.DB.Preload("Child").
Preload("Child.Group").
Preload("CreatedBy").
Preload("Recipients")
switch activeTab {
case "own":
if isAdminWithLocation {
// Admin sees all messages for children in the selected location's groups
if len(groupIDs) > 0 {
var childIDs []uint
for _, child := range groupChildren {
childIDs = append(childIDs, child.ID)
}
if len(childIDs) > 0 {
query = query.Where("child_id IN (?)", childIDs)
} else {
query = query.Where("1 = 0")
}
} else {
query = query.Where("1 = 0")
}
} else {
// Regular employee sees only messages they created
query = query.Where("created_by_id = ?", user.ID)
}
case "group":
// Show messages from colleagues in the same group(s), excluding own messages
if len(userGroupIDs) > 0 {
var childIDs []uint
model.DB.Model(&model.Child{}).
Where("group_id IN ?", userGroupIDs).
Pluck("id", &childIDs)
if len(childIDs) > 0 {
query = query.Where("created_by_id != ?", user.ID).
Where("child_id IN ?", childIDs).
Where("draft = ?", false)
} else {
query = query.Where("1 = 0")
}
} else {
query = query.Where("1 = 0")
}
// Update GroupMessagesLastViewedAt timestamp
now := time.Now()
model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("group_messages_last_viewed_at", now)
case "location":
// Show messages from colleagues at the same location(s), excluding own groups
locationIDs, _ := user.GetLocationIDs(model.DB)
if len(locationIDs) > 0 {
// Get all group IDs at those locations
var locationGroupIDs []uint
model.DB.Model(&model.Group{}).
Where("location_id IN ?", locationIDs).
Pluck("id", &locationGroupIDs)
// Exclude user's own groups
userGroupIDSet := make(map[uint]bool)
for _, id := range userGroupIDs {
userGroupIDSet[id] = true
}
var otherGroupIDs []uint
for _, id := range locationGroupIDs {
if !userGroupIDSet[id] {
otherGroupIDs = append(otherGroupIDs, id)
}
}
if len(otherGroupIDs) > 0 {
var childIDs []uint
model.DB.Model(&model.Child{}).
Where("group_id IN ?", otherGroupIDs).
Pluck("id", &childIDs)
if len(childIDs) > 0 {
query = query.Where("created_by_id != ?", user.ID).
Where("child_id IN ?", childIDs).
Where("draft = ?", false)
} else {
query = query.Where("1 = 0")
}
} else {
query = query.Where("1 = 0")
}
} else {
query = query.Where("1 = 0")
}
// Update LocationMessagesLastViewedAt timestamp
now := time.Now()
model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("location_messages_last_viewed_at", now)
}
// Apply filter if child ID is provided
if childID > 0 {
query = query.Where("child_id = ?", childID)
}
var messages []model.Message
query.Order("created_at DESC").Find(&messages)
// For each message, count read receipts and fetch answers
type ParentAnswer struct {
ParentName string
ParentRole string
Answer string
AnsweredAt time.Time
}
type BatchChildInfo struct {
ChildID uint
ChildName string
GroupName string
}
type MessageWithReadCount struct {
Message model.Message
ReadCount int
TotalRecipients int
AnswerCount int
Answers []ParentAnswer
IsColleague bool // True if this is a colleague's message (not user's own)
// Batch fields
IsBatch bool
BatchID string
ChildCount int
Children []BatchChildInfo
}
var messagesWithCounts []MessageWithReadCount
for _, msg := range messages {
var readCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND read_at IS NOT NULL", msg.ID).
Count(&readCount)
var answerCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND answer IS NOT NULL", msg.ID).
Count(&answerCount)
// Fetch answers with parent details for answer_possible and answer_required messages
var answers []ParentAnswer
if msg.InteractionType == "answer_possible" || msg.InteractionType == "answer_required" {
var messageReads []model.MessageRead
model.DB.Preload("User.Roles").Preload("User.Children").
Where("message_id = ? AND answer IS NOT NULL", msg.ID).
Order("answered_at ASC").
Find(&messageReads)
for _, mr := range messageReads {
if mr.Answer != nil && mr.AnsweredAt != nil {
roleName := "Unknown"
if len(mr.User.Roles) > 0 {
roleName = mr.User.Roles[0].Name
}
answers = append(answers, ParentAnswer{
ParentName: mr.User.DisplayNameWithChildren(),
ParentRole: roleName,
Answer: *mr.Answer,
AnsweredAt: *mr.AnsweredAt,
})
}
}
}
messagesWithCounts = append(messagesWithCounts, MessageWithReadCount{
Message: msg,
ReadCount: int(readCount),
TotalRecipients: len(msg.Recipients),
AnswerCount: int(answerCount),
Answers: answers,
IsColleague: msg.CreatedById != user.ID,
})
}
// Group messages by BatchID for employee view
// Messages with the same BatchID are collapsed into a single card
type batchAccumulator struct {
firstIndex int
readCount int
totalRecipients int
answerCount int
answers []ParentAnswer
children []BatchChildInfo
recipientIDs map[uint]bool // Track unique recipient IDs
}
batchMap := make(map[string]*batchAccumulator)
var batchOrder []string // preserve first-seen order
var groupedMessages []MessageWithReadCount
for i, mwc := range messagesWithCounts {
if mwc.Message.BatchID == nil {
// Individual message — keep as-is
groupedMessages = append(groupedMessages, mwc)
continue
}
bid := *mwc.Message.BatchID
if acc, exists := batchMap[bid]; exists {
// Accumulate stats into existing batch entry
acc.readCount += mwc.ReadCount
acc.answerCount += mwc.AnswerCount
acc.answers = append(acc.answers, mwc.Answers...)
// Track unique recipients
for _, recipient := range mwc.Message.Recipients {
acc.recipientIDs[recipient.ID] = true
}
groupName := ""
if mwc.Message.Child.Group != nil {
groupName = mwc.Message.Child.Group.Name
}
acc.children = append(acc.children, BatchChildInfo{
ChildID: mwc.Message.ChildId,
ChildName: mwc.Message.Child.FirstName + " " + mwc.Message.Child.LastName,
GroupName: groupName,
})
} else {
// First message in this batch
groupName := ""
if mwc.Message.Child.Group != nil {
groupName = mwc.Message.Child.Group.Name
}
// Initialize recipient tracking
recipientIDs := make(map[uint]bool)
for _, recipient := range mwc.Message.Recipients {
recipientIDs[recipient.ID] = true
}
batchMap[bid] = &batchAccumulator{
firstIndex: i,
readCount: mwc.ReadCount,
answerCount: mwc.AnswerCount,
answers: mwc.Answers,
recipientIDs: recipientIDs,
children: []BatchChildInfo{{
ChildID: mwc.Message.ChildId,
ChildName: mwc.Message.Child.FirstName + " " + mwc.Message.Child.LastName,
GroupName: groupName,
}},
}
batchOrder = append(batchOrder, bid)
// Add a placeholder (will be filled below)
groupedMessages = append(groupedMessages, MessageWithReadCount{})
}
}
// Fill in the batch placeholders with aggregated data
placeholderIdx := 0
for _, gm := range groupedMessages {
if gm.Message.BatchID == nil && gm.Message.ID == 0 {
// This is a placeholder — skip counting, we'll rebuild
break
}
placeholderIdx++
}
// Rebuild groupedMessages properly
var finalMessages []MessageWithReadCount
batchSeen := make(map[string]bool)
for _, mwc := range messagesWithCounts {
if mwc.Message.BatchID == nil {
finalMessages = append(finalMessages, mwc)
continue
}
bid := *mwc.Message.BatchID
if batchSeen[bid] {
continue // skip subsequent messages in same batch
}
batchSeen[bid] = true
acc := batchMap[bid]
entry := MessageWithReadCount{
Message: mwc.Message, // Use first message as representative
ReadCount: acc.readCount,
TotalRecipients: len(acc.recipientIDs), // Count unique recipients
AnswerCount: acc.answerCount,
Answers: acc.answers,
IsColleague: mwc.IsColleague,
IsBatch: true,
BatchID: bid,
ChildCount: len(acc.children),
Children: acc.children,
}
finalMessages = append(finalMessages, entry)
}
// Mark the employee's own answerable messages as viewed (clears response badges)
// Only do this on the "own" tab
if activeTab == "own" {
now := time.Now()
model.DB.Model(&model.Message{}).
Where("created_by_id = ?", user.ID).
Where("draft = ?", false).
Where("interaction_type IN ?", []string{"answer_possible", "answer_required"}).
Update("answers_last_viewed_at", now)
}
// Compute badges for the template
badgeService := service.NewNotificationBadgeService()
badges := badgeService.ComputeBadges(model.DB, user, "Employee", langStr, 0)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"messages": finalMessages,
"groupChildren": groupChildren,
"filterChildID": childID,
"activeTab": activeTab,
"canViewLocationMessages": canViewLocationMessages,
"badges": badges,
},
})
return
}
util.RenderHTMLOK(c, "parent-messages.html", gin.H{
"title": "Messages to Parents",
"user": user,
"messages": finalMessages,
"groupChildren": groupChildren,
"filterChildID": childID,
"activeTab": activeTab,
"canViewLocationMessages": canViewLocationMessages,
"badges": badges,
"lang": langStr,
})
}
// ViewBatchDetail displays the detail view for a batch of messages sent to multiple children
func ViewBatchDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
if !user.IsEmployee() && !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
batchID := c.Param("batchid")
if batchID == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Batch ID is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Batch ID is required",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch all messages in this batch
var batchMessages []model.Message
model.DB.Preload("Child").
Preload("Child.Group").
Preload("CreatedBy").
Preload("Recipients").
Where("batch_id = ?", batchID).
Order("created_at ASC").
Find(&batchMessages)
if len(batchMessages) == 0 {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Batch not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": user,
})
}
return
}
// Check access: user must be the creator or have visibility
firstMsg := batchMessages[0]
isOwner := firstMsg.CreatedById == user.ID
if !isOwner && !user.IsAdmin() {
// For non-owners, verify they have group/location visibility
// (same logic as colleague tab access)
userGroups, _ := user.GetEmployeeGroups(model.DB)
var userGroupIDs []uint
for _, g := range userGroups {
userGroupIDs = append(userGroupIDs, g.ID)
}
hasAccess := false
for _, msg := range batchMessages {
if msg.Child.GroupId != nil {
for _, gid := range userGroupIDs {
if gid == *msg.Child.GroupId {
hasAccess = true
break
}
}
}
if hasAccess {
break
}
}
if !hasAccess {
// Check location-level access
locationIDs, _ := user.GetLocationIDs(model.DB)
if len(locationIDs) > 0 {
var locationGroupIDs []uint
model.DB.Model(&model.Group{}).
Where("location_id IN ?", locationIDs).
Pluck("id", &locationGroupIDs)
locGroupSet := make(map[uint]bool)
for _, id := range locationGroupIDs {
locGroupSet[id] = true
}
for _, msg := range batchMessages {
if msg.Child.GroupId != nil && locGroupSet[*msg.Child.GroupId] {
hasAccess = true
break
}
}
}
}
if !hasAccess {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Access denied",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
}
// Build per-child detail info
type ChildDetail struct {
ChildName string
GroupName string
MessageID uint
ReadCount int
TotalRecipients int
AnswerCount int
}
var childDetails []ChildDetail
totalReadCount := 0
totalAnswerCount := 0
anyRead := false
// Track unique recipients across the entire batch
uniqueRecipientIDs := make(map[uint]bool)
for _, msg := range batchMessages {
var readCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND read_at IS NOT NULL", msg.ID).
Count(&readCount)
var answerCount int64
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND answer IS NOT NULL", msg.ID).
Count(&answerCount)
groupName := ""
if msg.Child.Group != nil {
groupName = msg.Child.Group.Name
}
childDetails = append(childDetails, ChildDetail{
ChildName: msg.Child.FirstName + " " + msg.Child.LastName,
GroupName: groupName,
MessageID: msg.ID,
ReadCount: int(readCount),
TotalRecipients: len(msg.Recipients),
AnswerCount: int(answerCount),
})
totalReadCount += int(readCount)
// Track unique recipients
for _, recipient := range msg.Recipients {
uniqueRecipientIDs[recipient.ID] = true
}
totalAnswerCount += int(answerCount)
if readCount > 0 {
anyRead = true
}
}
// Count unique recipients across the batch
totalRecipients := len(uniqueRecipientIDs)
canEdit := isOwner && !anyRead
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"batchID": batchID,
"subject": firstMsg.Subject,
"text": firstMsg.Text,
"interactionType": firstMsg.InteractionType,
"deadline": firstMsg.Deadline,
"createdBy": firstMsg.CreatedBy,
"createdAt": firstMsg.CreatedAt,
"editedAt": firstMsg.EditedAt,
"childDetails": childDetails,
"totalReadCount": totalReadCount,
"totalRecipients": totalRecipients,
"totalAnswers": totalAnswerCount,
"childCount": len(batchMessages),
"canEdit": canEdit,
"isOwner": isOwner,
},
})
return
}
util.RenderHTMLOK(c, "parent-message-batch-detail.html", gin.H{
"title": "Batch Message Detail",
"user": user,
"batchID": batchID,
"message": firstMsg,
"childDetails": childDetails,
"totalReadCount": totalReadCount,
"totalRecipients": totalRecipients,
"totalAnswers": totalAnswerCount,
"childCount": len(batchMessages),
"canEdit": canEdit,
"isOwner": isOwner,
"lang": langStr,
})
}
// SendMessageForm displays the form to send a message to parents
func SendMessageForm(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is an employee
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get optional child ID from URL parameter
childIDParam := c.Param("childid")
var selectedChildID uint
if childIDParam != "" {
childIDUint64, err := strconv.ParseUint(childIDParam, 10, 32)
if err == nil {
selectedChildID = uint(childIDUint64)
}
}
// Get groups based on user role and location's EmployeeLocationAccess setting
var groups []model.Group
var groupIDs []uint
// First, always get the user's assigned groups with their locations
// (handles intranet-linked employees)
assignedGroups, _ := user.GetEmployeeGroups(model.DB)
// Build a map of location IDs where user can access all children
accessibleLocationIDs := make(map[uint]bool)
// LocationLeads can always access all groups at locations they lead
if user.IsHouseLeader() {
var leadLocations []model.Location
model.DB.Where("lead_id = ? OR lead2nd_id = ?", user.ID, user.ID).Find(&leadLocations)
for _, loc := range leadLocations {
accessibleLocationIDs[loc.ID] = true
}
}
// Check each assigned group's location for access based on EmployeeLocationAccess setting
for _, g := range assignedGroups {
if g.Location != nil && g.Location.CanEmployeeAccessAllChildren(user) {
accessibleLocationIDs[g.LocationId] = true
}
}
// Get all groups at accessible locations
if len(accessibleLocationIDs) > 0 {
var locationIDSlice []uint
for locID := range accessibleLocationIDs {
locationIDSlice = append(locationIDSlice, locID)
}
model.DB.Where("location_id IN (?)", locationIDSlice).Find(&groups)
} else {
// No location-wide access, only use assigned groups
groups = assignedGroups
}
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
// Fetch all children in the accessible groups
var groupChildren []model.Child
if len(groupIDs) > 0 {
model.DB.Where("group_id IN (?)", groupIDs).
Preload("Group").
Order("first_name ASC").
Find(&groupChildren)
}
// Fetch message templates
var templates []model.MessageTemplate
model.DB.Where("created_by_id = ?", user.ID).
Order("name ASC").
Find(&templates)
// Fetch clusters for quick child selection
clusters, _ := service.GetClustersForGroups(model.DB, groupIDs)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"groupChildren": groupChildren,
"templates": templates,
"clusters": clusters,
"selectedChildID": selectedChildID,
},
})
return
}
util.RenderHTMLOK(c, "parent-message-form.html", gin.H{
"title": "Send Message to Parents",
"user": user,
"groupChildren": groupChildren,
"templates": templates,
"clusters": clusters,
"selectedChildID": selectedChildID,
"lang": langStr,
})
}
// SendParentMessage handles the form submission to send a message to one or more children
func SendParentMessage(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is an employee
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can send messages",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Parse form data - support both single child_id (legacy) and child_ids[] (multi-select)
childIDStrs := c.PostFormArray("child_ids[]")
// Fallback to single child_id for backward compatibility
if len(childIDStrs) == 0 {
singleChildID := c.PostForm("child_id")
if singleChildID != "" {
childIDStrs = []string{singleChildID}
}
}
subject := strings.TrimSpace(c.PostForm("subject"))
text := strings.TrimSpace(c.PostForm("text"))
interactionType := c.PostForm("interaction_type")
deadlineStr := c.PostForm("deadline")
// Validate required fields - at least one child must be selected
if len(childIDStrs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Please select at least one child",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Please select at least one child",
"lang": langStr,
"user": user,
})
}
return
}
// Parse all child IDs
var childIDs []uint
for _, idStr := range childIDStrs {
childID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid child ID: " + idStr,
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid child ID",
"lang": langStr,
"user": user,
})
}
return
}
childIDs = append(childIDs, uint(childID))
}
if subject == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Subject is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Subject is required",
"lang": langStr,
"user": user,
})
}
return
}
if text == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Message text is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Message text is required",
"lang": langStr,
"user": user,
})
}
return
}
// Validate interaction type
validInteractionTypes := []string{"informal", "answer_possible", "answer_required"}
isValidInteractionType := false
for _, valid := range validInteractionTypes {
if interactionType == valid {
isValidInteractionType = true
break
}
}
if !isValidInteractionType {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid interaction type",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid interaction type",
"lang": langStr,
"user": user,
})
}
return
}
// Parse deadline if provided
var deadline *time.Time
if deadlineStr != "" {
parsedDeadline, err := time.Parse("2006-01-02", deadlineStr)
if err == nil {
deadline = &parsedDeadline
}
}
// Get accessible groups for access validation based on EmployeeLocationAccess setting
var accessibleGroups []model.Group
// First, always get the user's assigned groups with their locations
// (handles intranet-linked employees)
assignedGroups, _ := user.GetEmployeeGroups(model.DB)
// Build a map of location IDs where user can access all children
accessibleLocationIDs := make(map[uint]bool)
// LocationLeads can always access all groups at locations they lead
if user.IsHouseLeader() {
var leadLocations []model.Location
model.DB.Where("lead_id = ? OR lead2nd_id = ?", user.ID, user.ID).Find(&leadLocations)
for _, loc := range leadLocations {
accessibleLocationIDs[loc.ID] = true
}
}
// Check each assigned group's location for access based on EmployeeLocationAccess setting
for _, g := range assignedGroups {
if g.Location != nil && g.Location.CanEmployeeAccessAllChildren(user) {
accessibleLocationIDs[g.LocationId] = true
}
}
// Get all groups at accessible locations
if len(accessibleLocationIDs) > 0 {
var locationIDSlice []uint
for locID := range accessibleLocationIDs {
locationIDSlice = append(locationIDSlice, locID)
}
model.DB.Where("location_id IN (?)", locationIDSlice).Find(&accessibleGroups)
} else {
// No location-wide access, only use assigned groups
accessibleGroups = assignedGroups
}
accessibleGroupIDs := make(map[uint]bool)
for _, g := range accessibleGroups {
accessibleGroupIDs[g.ID] = true
}
// Create a message for each selected child using a transaction
now := time.Now()
var createdMessages []model.Message
var skippedChildren []uint
// Generate a BatchID if sending to multiple children
var batchID *string
if len(childIDs) > 1 {
id := uuid.New().String()
batchID = &id
}
tx := model.DB.Begin()
if tx.Error != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to start transaction",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to start transaction",
"lang": langStr,
"user": user,
})
}
return
}
for _, childID := range childIDs {
// Verify child exists and employee has access
var child model.Child
if err := tx.Preload("Group").Preload("Users").First(&child, childID).Error; err != nil {
// Skip children that don't exist
skippedChildren = append(skippedChildren, childID)
continue
}
// Check if employee has access to this child's group
if child.GroupId == nil || !accessibleGroupIDs[*child.GroupId] {
// Skip children the employee doesn't have access to
skippedChildren = append(skippedChildren, childID)
continue
}
// Skip children with no parent accounts
if len(child.Users) == 0 {
skippedChildren = append(skippedChildren, childID)
continue
}
// Create the message for this child
message := model.Message{
CreatedById: user.ID,
ChildId: childID,
Subject: subject,
Text: text,
InteractionType: interactionType,
Deadline: deadline,
Draft: false,
PublishedAt: &now,
BatchID: batchID,
}
if err := tx.Create(&message).Error; err != nil {
tx.Rollback()
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to create message",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to create message",
"lang": langStr,
"user": user,
})
}
return
}
// Add recipients (all parent users of this child)
if err := tx.Model(&message).Association("Recipients").Append(child.Users); err != nil {
tx.Rollback()
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to add recipients",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to add recipients",
"lang": langStr,
"user": user,
})
}
return
}
createdMessages = append(createdMessages, message)
}
// Check if any messages were created
if len(createdMessages) == 0 {
tx.Rollback()
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "No valid children selected or no parent accounts linked",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "No valid children selected or no parent accounts linked",
"lang": langStr,
"user": user,
})
}
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to commit transaction",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to commit transaction",
"lang": langStr,
"user": user,
})
}
return
}
// Send email notifications for each created message
for i := range createdMessages {
service.NotifyMessagePublished(model.DB, &createdMessages[i])
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"messages": createdMessages,
"count": len(createdMessages),
"skippedChildren": skippedChildren,
},
})
return
}
// Redirect to messages list
c.Redirect(http.StatusSeeOther, "/"+langStr+"/messages")
}
// EditMessageForm displays the form to edit a message (only if not read)
func EditMessageForm(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is an employee
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get message ID from URL parameter
messageIDParam := c.Param("id")
messageID, err := strconv.ParseUint(messageIDParam, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid message ID",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid message ID",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the message
var message model.Message
if err := model.DB.Preload("Child").
Preload("Child.Group").
Preload("Recipients").
First(&message, uint(messageID)).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Message not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": user,
})
}
return
}
// Check if user is the message creator
if message.CreatedById != user.ID {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You can only edit your own messages",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Check if message (or any batch sibling) has been read by any recipient
var readCount int64
if message.BatchID != nil {
// For batched messages, check all messages in the batch
var batchMessageIDs []uint
model.DB.Model(&model.Message{}).
Where("batch_id = ?", *message.BatchID).
Pluck("id", &batchMessageIDs)
model.DB.Model(&model.MessageRead{}).
Where("message_id IN ? AND read_at IS NOT NULL", batchMessageIDs).
Count(&readCount)
} else {
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND read_at IS NOT NULL", message.ID).
Count(&readCount)
}
if readCount > 0 {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Cannot edit message that has been read",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "error.html", gin.H{
"title": "Error",
"error": "Cannot edit message that has been read",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch message templates
var templates []model.MessageTemplate
model.DB.Where("created_by_id = ?", user.ID).
Order("name ASC").
Find(&templates)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": message,
"templates": templates,
},
})
return
}
util.RenderHTMLOK(c, "parent-message-edit.html", gin.H{
"title": "Edit Message",
"user": user,
"message": message,
"templates": templates,
"lang": langStr,
})
}
// UpdateParentMessage handles the form submission to update a message
func UpdateParentMessage(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is an employee
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only employees can update messages",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get message ID from URL parameter
messageIDParam := c.Param("id")
messageID, err := strconv.ParseUint(messageIDParam, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid message ID",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid message ID",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the message
var message model.Message
if err := model.DB.First(&message, uint(messageID)).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Message not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": user,
})
}
return
}
// Check if user is the message creator
if message.CreatedById != user.ID {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You can only edit your own messages",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Check if message (or any batch sibling) has been read by any recipient
var readCount int64
if message.BatchID != nil {
var batchMessageIDs []uint
model.DB.Model(&model.Message{}).
Where("batch_id = ?", *message.BatchID).
Pluck("id", &batchMessageIDs)
model.DB.Model(&model.MessageRead{}).
Where("message_id IN ? AND read_at IS NOT NULL", batchMessageIDs).
Count(&readCount)
} else {
model.DB.Model(&model.MessageRead{}).
Where("message_id = ? AND read_at IS NOT NULL", message.ID).
Count(&readCount)
}
if readCount > 0 {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Cannot edit message that has been read",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "error.html", gin.H{
"title": "Error",
"error": "Cannot edit message that has been read",
"lang": langStr,
"user": user,
})
}
return
}
// Parse form data
subject := strings.TrimSpace(c.PostForm("subject"))
text := strings.TrimSpace(c.PostForm("text"))
interactionType := c.PostForm("interaction_type")
deadlineStr := c.PostForm("deadline")
// Validate required fields
if subject == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Subject is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Subject is required",
"lang": langStr,
"user": user,
})
}
return
}
if text == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Message text is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Message text is required",
"lang": langStr,
"user": user,
})
}
return
}
// Validate interaction type
validInteractionTypes := []string{"informal", "answer_possible", "answer_required"}
isValidInteractionType := false
for _, valid := range validInteractionTypes {
if interactionType == valid {
isValidInteractionType = true
break
}
}
if !isValidInteractionType {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid interaction type",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid interaction type",
"lang": langStr,
"user": user,
})
}
return
}
// Parse deadline if provided
var deadline *time.Time
if deadlineStr != "" {
parsedDeadline, err := time.Parse("2006-01-02", deadlineStr)
if err == nil {
deadline = &parsedDeadline
}
}
// Update the message (and batch siblings if applicable)
now := time.Now()
userID := user.ID
if message.BatchID != nil {
// Update all messages in the batch
updates := map[string]interface{}{
"subject": subject,
"text": text,
"interaction_type": interactionType,
"deadline": deadline,
"edited_at": &now,
"last_edited_by_id": &userID,
}
if err := model.DB.Model(&model.Message{}).
Where("batch_id = ?", *message.BatchID).
Updates(updates).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to update messages",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to update messages",
"lang": langStr,
"user": user,
})
}
return
}
} else {
message.Subject = subject
message.Text = text
message.InteractionType = interactionType
message.Deadline = deadline
message.EditedAt = &now
message.LastEditedById = &userID
if err := model.DB.Save(&message).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to update message",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to update message",
"lang": langStr,
"user": user,
})
}
return
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": message,
},
})
return
}
// Redirect to messages list
c.Redirect(http.StatusSeeOther, "/"+langStr+"/messages")
}
// ========================================
// Parent-Side Message Functions
// ========================================
// ParentViewReceivedMessages displays messages received by the parent for their children
func ParentViewReceivedMessages(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only parents can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get optional child filter
childIDParam := c.Query("child_id")
var childID uint
if childIDParam != "" {
parsed, err := strconv.ParseUint(childIDParam, 10, 32)
if err == nil {
childID = uint(parsed)
}
}
// Fetch parent's children with valid relationships
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{"code": "DATABASE_ERROR", "message": "failed to get children"}})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Convert to []model.Child for template compatibility
var children []model.Child
for _, child := range validChildren {
children = append(children, *child)
}
// If parent has no valid children, show empty messages list
if len(validChildren) == 0 {
type MessageWithStatus struct {
Message model.Message
IsRead bool
ReadAt *time.Time
HasAnswered bool
Answer *string
AnsweredAt *time.Time
NeedsAnswer bool
}
var messagesWithStatus []MessageWithStatus
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"messages": messagesWithStatus,
"children": children,
"filterChildID": childID,
},
})
} else {
util.RenderHTMLOK(c, "parent-received-messages.html", gin.H{
"title": "Messages",
"user": user,
"messages": messagesWithStatus,
"children": children,
"filterChildID": childID,
"lang": langStr,
})
}
return
}
// Build query for messages
query := model.DB.Joins("JOIN message_recipients ON messages.id = message_recipients.message_id").
Where("message_recipients.user_id = ? AND messages.draft = ?", user.ID, false).
Preload("Child").
Preload("Child.Group").
Preload("CreatedBy")
// Apply child filter if specified
if childID > 0 {
query = query.Where("messages.child_id = ?", childID)
}
var messages []model.Message
query.Order("messages.created_at DESC").Find(&messages)
// For each message, fetch read status and answer status
type MessageWithStatus struct {
Message model.Message
IsRead bool
ReadAt *time.Time
HasAnswered bool
Answer *string
AnsweredAt *time.Time
NeedsAnswer bool
}
var messagesWithStatus []MessageWithStatus
for _, msg := range messages {
// Check if parent has read this message
var messageRead model.MessageRead
err := model.DB.Where("message_id = ? AND user_id = ?", msg.ID, user.ID).First(&messageRead).Error
isRead := err == nil
var readAt *time.Time
var hasAnswered bool
var answer *string
var answeredAt *time.Time
if isRead {
readAt = messageRead.ReadAt
hasAnswered = messageRead.Answer != nil && *messageRead.Answer != ""
answer = messageRead.Answer
answeredAt = messageRead.AnsweredAt
}
needsAnswer := msg.InteractionType == "answer_required" && !hasAnswered
messagesWithStatus = append(messagesWithStatus, MessageWithStatus{
Message: msg,
IsRead: isRead,
ReadAt: readAt,
HasAnswered: hasAnswered,
Answer: answer,
AnsweredAt: answeredAt,
NeedsAnswer: needsAnswer,
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"messages": messagesWithStatus,
"children": children,
"filterChildID": childID,
},
})
return
}
util.RenderHTMLOK(c, "parent-received-messages.html", gin.H{
"title": "Messages",
"user": user,
"messages": messagesWithStatus,
"children": children,
"filterChildID": childID,
"lang": langStr,
})
}
// ParentViewMessageDetail displays a single message in detail and marks it as read
func ParentViewMessageDetail(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only parents can access this page",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get message ID from URL parameter
messageIDParam := c.Param("id")
messageID, err := strconv.ParseUint(messageIDParam, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid message ID",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid message ID",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the message
var message model.Message
if err := model.DB.Preload("Child").
Preload("Child.Group").
Preload("Child.Group.Location").
Preload("CreatedBy").
Preload("Recipients").
First(&message, uint(messageID)).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Message not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": user,
})
}
return
}
// Verify user is a recipient of this message
isRecipient := false
for _, recipient := range message.Recipients {
if recipient.ID == user.ID {
isRecipient = true
break
}
}
if !isRecipient {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You don't have access to this message",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Check if message has been read by this user
var messageRead model.MessageRead
err = model.DB.Where("message_id = ? AND user_id = ?", message.ID, user.ID).First(&messageRead).Error
if err != nil {
// Message not read yet, create read receipt
now := time.Now()
messageRead = model.MessageRead{
MessageId: message.ID,
UserId: user.ID,
ReadAt: &now,
}
if err := model.DB.Create(&messageRead).Error; err != nil {
// Log error but don't fail the request
// TODO: Add proper logging
}
}
hasAnswered := messageRead.Answer != nil && *messageRead.Answer != ""
canAnswer := (message.InteractionType == "answer_possible" || message.InteractionType == "answer_required") && !hasAnswered
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"message": message,
"messageRead": messageRead,
"hasAnswered": hasAnswered,
"canAnswer": canAnswer,
},
})
return
}
util.RenderHTMLOK(c, "parent-message-detail.html", gin.H{
"title": "Message",
"user": user,
"message": message,
"messageRead": messageRead,
"hasAnswered": hasAnswered,
"canAnswer": canAnswer,
"lang": langStr,
})
}
// ParentSubmitAnswer handles the submission of an answer to a message
func ParentSubmitAnswer(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": getUserOrDummyMessages(c),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only parents can submit answers",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get message ID from URL parameter
messageIDParam := c.Param("id")
messageID, err := strconv.ParseUint(messageIDParam, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid message ID",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid message ID",
"lang": langStr,
"user": user,
})
}
return
}
// Get answer from form
answer := strings.TrimSpace(c.PostForm("answer"))
if answer == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Answer text is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Answer text is required",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the message
var message model.Message
if err := model.DB.Preload("Recipients").First(&message, uint(messageID)).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Message not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "error.html", gin.H{
"title": "Error",
"error": "Message not found",
"lang": langStr,
"user": user,
})
}
return
}
// Verify user is a recipient
isRecipient := false
for _, recipient := range message.Recipients {
if recipient.ID == user.ID {
isRecipient = true
break
}
}
if !isRecipient {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You don't have access to this message",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Check if message allows answers
if message.InteractionType != "answer_possible" && message.InteractionType != "answer_required" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "This message does not allow answers",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "This message does not allow answers",
"lang": langStr,
"user": user,
})
}
return
}
// Find or create MessageRead record
var messageRead model.MessageRead
err = model.DB.Where("message_id = ? AND user_id = ?", message.ID, user.ID).First(&messageRead).Error
if err != nil {
// Create new read record with answer
now := time.Now()
messageRead = model.MessageRead{
MessageId: message.ID,
UserId: user.ID,
ReadAt: &now,
Answer: &answer,
AnsweredAt: &now,
}
if err := model.DB.Create(&messageRead).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to save answer",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to save answer",
"lang": langStr,
"user": user,
})
}
return
}
} else {
// Update existing record with answer
now := time.Now()
messageRead.Answer = &answer
messageRead.AnsweredAt = &now
if err := model.DB.Save(&messageRead).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to save answer",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to save answer",
"lang": langStr,
"user": user,
})
}
return
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"messageRead": messageRead,
},
})
return
}
// Redirect back to message detail
c.Redirect(http.StatusSeeOther, "/"+langStr+"/parent/messages/"+messageIDParam)
}
package controller
// Parental letters controller - handles letters from employees to parents
// Supports read confirmations and response collection
//
// [impl->dsn~elternbriefe-design~1]
import (
"io"
"net/http"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/storage"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// multipartFileInfo holds validated file upload data
type multipartFileInfo struct {
Filename string
MimeType string
Content []byte
}
// PreviewMarkdown returns rendered markdown (for live preview via AJAX)
// This is a general-purpose endpoint for any authenticated user
func PreviewMarkdown(c *gin.Context) {
_, ok := c.Get("User")
if !ok {
c.String(http.StatusUnauthorized, "Unauthorized")
return
}
markdown := c.PostForm("markdown")
html := util.RenderMarkdown(markdown)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
// ParentalLetters routes to appropriate list view based on user role
func ParentalLetters(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
util.RenderHTML(c, http.StatusUnauthorized, "401.html", gin.H{"lang": langStr})
}
return
}
user := userInterface.(*model.User)
// Check if admin with location selected
adminLocId, hasAdminLoc := c.Get("adminLocationId")
isAdminWithLocation := user.IsAdmin() && hasAdminLoc && adminLocId.(int) > 0
// Get active role for view selection (supports dual-role users)
activeRole, _ := c.Get("activeRole")
activeRoleStr, _ := activeRole.(string)
// Route to appropriate view based on active role
showEmployeeView := activeRoleStr == "Employee" || activeRoleStr == "GroupLead" || activeRoleStr == "LocationLead"
if showEmployeeView && user.IsHouseLeader() {
ListLettersLocation(c)
} else if showEmployeeView && user.IsEmployee() {
ListLettersEmployee(c)
} else if user.IsParent() {
ListLettersParent(c)
} else if isAdminWithLocation {
// Admin with selected location sees letters for that location
ListLettersAdminLocation(c, uint(adminLocId.(int)))
} else if user.IsAdmin() {
// Admin without location selected - show ALL letters across all locations
ListLettersAdminAllLocations(c)
} else {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
}
}
// ListLettersParent shows published letters for parent's children
func ListLettersParent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get parent's children with valid relationships
validChildren, err := model.GetValidChildrenForUser(model.DB, user.ID)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get children"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// If parent has no valid children, show empty letters list
if len(validChildren) == 0 {
type LetterWithRead struct {
Letter model.ParentalLetter
Read bool
}
var lettersWithRead []LetterWithRead
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithRead,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-parent.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithRead,
"title": "Parental Letters",
})
}
return
}
// Collect group IDs and location IDs from valid children
groupIDsMap := make(map[uint]bool)
locationIDsMap := make(map[uint]bool)
for _, child := range validChildren {
if child.Group != nil && child.GroupId != nil {
groupIDsMap[*child.GroupId] = true
locationIDsMap[child.Group.LocationId] = true
}
}
var groupIDs []uint
for id := range groupIDsMap {
groupIDs = append(groupIDs, id)
}
var locationIDs []uint
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
// Get all published letters for parent's children's groups OR location-wide letters at those locations
var letters []model.ParentalLetter
if len(groupIDs) > 0 {
err := model.DB.Where(
"review_status = ? AND deleted_at IS NULL AND (group_id IN ? OR (group_id IS NULL AND location_id IN ?))",
"published",
groupIDs,
locationIDs,
).Order("published_at DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Check which letters have been read/completed
type LetterWithRead struct {
Letter model.ParentalLetter
Read bool
}
var lettersWithRead []LetterWithRead
for _, letter := range letters {
var readRecord model.ParentalLetterRead
err := model.DB.Where("letter_id = ? AND user_id = ?", letter.ID, user.ID).First(&readRecord).Error
// Determine if letter is considered "read/completed"
// For answer_required letters, they must be answered to be considered complete
isRead := false
if err == nil && readRecord.ReadAt != nil {
if letter.InteractionType == "answer_required" {
// Must have an answer to be considered complete
isRead = readRecord.Answer != nil && *readRecord.Answer != ""
} else {
// For informal and answer_possible, just viewing is enough
isRead = true
}
}
lettersWithRead = append(lettersWithRead, LetterWithRead{
Letter: letter,
Read: isRead,
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithRead,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-parent.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithRead,
"title": "Parental Letters",
})
}
}
// ListLettersEmployee shows letters for employees and group leaders
func ListLettersEmployee(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Check if user is employee (GroupLead and LocationLead also have Employee role)
if !user.IsEmployee() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get employee's groups (handles intranet-linked employees)
groups, _ := user.GetEmployeeCoreTeamGroups(model.DB)
var groupIDs []uint
locationIDsMap := make(map[uint]bool)
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
locationIDsMap[g.LocationId] = true
}
var locationIDs []uint
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
// Get all letters for employee's groups
// Include: published/approved letters (for specific groups OR location-wide at employee's locations),
// drafts created by this user, letters delegated TO this user, AND letters pending review by this user
var letters []model.ParentalLetter
if len(groupIDs) > 0 {
err := model.DB.Where(
"(review_status IN ? AND (group_id IN ? OR (group_id IS NULL AND location_id IN ?))) OR (created_by_id = ? AND group_id IN ?) OR (delegated_to_id = ?) OR (reviewer_id = ? AND review_status = ?)",
[]string{"published", "approved"},
groupIDs,
locationIDs,
user.ID,
groupIDs,
user.ID,
user.ID,
"pending_review",
).Order("CASE WHEN published_at IS NULL THEN 0 ELSE 1 END, COALESCE(published_at, created_at) DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Preload("DelegatedTo").
Preload("Reviewer").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
} else {
// If employee has no groups, show no letters
letters = []model.ParentalLetter{}
}
// Add read statistics and edit permission for each letter
type LetterWithStats struct {
Letter model.ParentalLetter
ReadCount int
UnreadCount int
TotalCount int
CanEdit bool
}
var lettersWithStats []LetterWithStats
for _, letter := range letters {
read, unread, _ := service.GetReadStatistics(model.DB, letter.ID)
lettersWithStats = append(lettersWithStats, LetterWithStats{
Letter: letter,
ReadCount: len(read),
UnreadCount: len(unread),
TotalCount: len(read) + len(unread),
CanEdit: service.CanUserEditLetter(model.DB, user, &letter),
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithStats,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-employee.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithStats,
"title": "Parental Letters",
"isAdmin": false,
})
}
}
// ListLettersLocation shows letters for location leaders
func ListLettersLocation(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsHouseLeader() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get location leader's location IDs to scope letters
locationIDs, err := user.GetLocationIDs(model.DB)
if err != nil || len(locationIDs) == 0 {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "could not determine locations"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Show published/approved letters scoped to user's locations, drafts created by this user, AND letters pending review by this user
var letters []model.ParentalLetter
err = model.DB.Where(
"(review_status IN ? AND location_id IN ?) OR (created_by_id = ? AND location_id IN ?) OR (reviewer_id = ? AND review_status = ? AND location_id IN ?)",
[]string{"published", "approved"},
locationIDs,
user.ID,
locationIDs,
user.ID,
"pending_review",
locationIDs,
).Order("CASE WHEN published_at IS NULL THEN 0 ELSE 1 END, COALESCE(published_at, created_at) DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Preload("DelegatedTo").
Preload("Reviewer").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Add read statistics and edit permission
type LetterWithStats struct {
Letter model.ParentalLetter
ReadCount int
UnreadCount int
TotalCount int
CanEdit bool
}
var lettersWithStats []LetterWithStats
for _, letter := range letters {
read, unread, _ := service.GetReadStatistics(model.DB, letter.ID)
lettersWithStats = append(lettersWithStats, LetterWithStats{
Letter: letter,
ReadCount: len(read),
UnreadCount: len(unread),
TotalCount: len(read) + len(unread),
CanEdit: service.CanUserEditLetter(model.DB, user, &letter),
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithStats,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-employee.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithStats,
"title": "Parental Letters",
"isAdmin": false,
})
}
}
// ListLettersAdminAllLocations shows ALL letters across all locations (for admin users with "All Locations" selected)
func ListLettersAdminAllLocations(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get ALL letters across all locations (all statuses for admin)
var letters []model.ParentalLetter
err := model.DB.Order("CASE WHEN published_at IS NULL THEN 0 ELSE 1 END, COALESCE(published_at, created_at) DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Preload("DelegatedTo").
Preload("Reviewer").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Add read statistics and canEdit flag
type LetterWithStats struct {
Letter model.ParentalLetter
ReadCount int
UnreadCount int
TotalCount int
CanEdit bool
}
var lettersWithStats []LetterWithStats
for _, letter := range letters {
read, unread, _ := service.GetReadStatistics(model.DB, letter.ID)
lettersWithStats = append(lettersWithStats, LetterWithStats{
Letter: letter,
ReadCount: len(read),
UnreadCount: len(unread),
TotalCount: len(read) + len(unread),
CanEdit: service.CanAdminEditLetter(model.DB, &letter),
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithStats,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-employee.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithStats,
"title": "Parental Letters",
"isAdmin": true,
"showAllLocations": true,
})
}
}
// ListLettersAdminLocation shows letters for a specific location (for admin users)
func ListLettersAdminLocation(c *gin.Context, locationID uint) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get all groups in the selected location
var groups []model.Group
model.DB.Where("location_id = ?", locationID).Find(&groups)
var groupIDs []uint
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
// Get ALL letters for the location (all statuses for admin)
var letters []model.ParentalLetter
if len(groupIDs) > 0 {
err := model.DB.Where(
"location_id = ? OR (group_id IN ? AND location_id = ?)",
locationID,
groupIDs,
locationID,
).Order("CASE WHEN published_at IS NULL THEN 0 ELSE 1 END, COALESCE(published_at, created_at) DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Preload("DelegatedTo").
Preload("Reviewer").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
} else {
// No groups in location - only show location-wide letters
err := model.DB.Where(
"location_id = ? AND group_id IS NULL",
locationID,
).Order("CASE WHEN published_at IS NULL THEN 0 ELSE 1 END, COALESCE(published_at, created_at) DESC").
Preload("Group").
Preload("Location").
Preload("CreatedBy").
Preload("DelegatedTo").
Preload("Reviewer").
Find(&letters).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Add read statistics and canEdit flag
type LetterWithStats struct {
Letter model.ParentalLetter
ReadCount int
UnreadCount int
TotalCount int
CanEdit bool
}
var lettersWithStats []LetterWithStats
for _, letter := range letters {
read, unread, _ := service.GetReadStatistics(model.DB, letter.ID)
lettersWithStats = append(lettersWithStats, LetterWithStats{
Letter: letter,
ReadCount: len(read),
UnreadCount: len(unread),
TotalCount: len(read) + len(unread),
CanEdit: service.CanAdminEditLetter(model.DB, &letter),
})
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letters": lettersWithStats,
})
} else {
util.RenderHTMLOK(c, "parental-letters-list-employee.html", gin.H{
"lang": langStr,
"user": user,
"letters": lettersWithStats,
"title": "Parental Letters",
"isAdmin": true,
})
}
}
// ViewLetterParent shows a letter to a parent and marks it as read
func ViewLetterParent(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.Preload("Group").Preload("Group.Location").Preload("Location").Preload("CreatedBy").Preload("CreatedAsStandInFor").First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): replaced hand-rolled JOIN user_children
// pattern with service.CanUserViewLetter so every parent endpoint
// shares one auditable rule.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
// Check if parent has already answered
var letterRead model.ParentalLetterRead
err = model.DB.Where("letter_id = ? AND user_id = ?", letter.ID, user.ID).First(&letterRead).Error
hasAnswered := false
var existingAnswer *string
var answeredAt *time.Time
if err == nil {
hasAnswered = letterRead.Answer != nil && *letterRead.Answer != ""
existingAnswer = letterRead.Answer
answeredAt = letterRead.AnsweredAt
}
canAnswer := (letter.InteractionType == "answer_possible" || letter.InteractionType == "answer_required") && !hasAnswered
// Format text
htmlContent := util.RenderMarkdown(letter.Text)
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByLetter(letter.ID)
// Prepare template data
templateData := gin.H{
"lang": langStr,
"user": user,
"letter": letter,
"htmlContent": htmlContent,
"hasAnswered": hasAnswered,
"canAnswer": canAnswer,
"existingAnswer": existingAnswer,
"answeredAt": answeredAt,
"attachments": attachments,
"title": "Parental Letter",
}
// Load poll data if poll type
if letter.InteractionType == "poll_single" || letter.InteractionType == "poll_multi" {
pollOptions, err := service.GetPollOptions(model.DB, letter.ID)
if err == nil {
templateData["pollOptions"] = pollOptions
}
hasVoted := service.HasUserVoted(model.DB, letter.ID, user.ID)
templateData["hasVoted"] = hasVoted
if hasVoted {
// Show results after voting
results, totalVoters, err := service.GetPollResults(model.DB, letter.ID, letter.IsAnonymousPoll)
if err == nil {
templateData["pollResults"] = results
templateData["totalVoters"] = totalVoters
}
userVotes, _ := service.GetUserVotes(model.DB, letter.ID, user.ID)
templateData["userVotes"] = userVotes
}
}
// Load table data if table type
if letter.InteractionType == "table" {
tableData, err := service.GetTableData(model.DB, letter.ID)
if err == nil {
templateData["tableData"] = tableData
}
// Check if this is a user-entry style table (no predefined rows)
hasPredefinedRows := service.HasPredefinedRows(model.DB, letter.ID)
templateData["hasPredefinedRows"] = hasPredefinedRows
if hasPredefinedRows {
// Traditional table with predefined rows - load user's cell values
userEntries, _ := service.GetUserTableEntries(model.DB, letter.ID, user.ID)
// Convert to map for easy lookup in template
userCellMap := make(map[string]string)
for _, cell := range userEntries {
key := strconv.FormatUint(uint64(cell.RowId), 10) + "_" + strconv.FormatUint(uint64(cell.ColumnId), 10)
userCellMap[key] = cell.Value
}
templateData["userCellMap"] = userCellMap
} else {
// User-entry style table - load all user-created entries
tableEntries, _ := service.GetTableEntriesWithUsers(model.DB, letter.ID)
templateData["tableEntries"] = tableEntries
// Get the columns for the form
columns, _ := service.GetTableColumns(model.DB, letter.ID)
templateData["tableColumns"] = columns
}
}
// Load survey data if survey type
if letter.InteractionType == "survey" {
surveyQuestions, err := service.GetSurveyQuestions(model.DB, letter.ID)
if err == nil {
templateData["surveyQuestions"] = surveyQuestions
}
hasResponded := service.HasUserRespondedToSurvey(model.DB, letter.ID, user.ID)
templateData["hasRespondedToSurvey"] = hasResponded
if hasResponded {
// Show results after responding (respecting HidePollResults)
if !letter.HidePollResults {
// For parent view, ALWAYS show anonymous results to prevent gossip
// (regardless of IsAnonymousPoll setting - that only affects employee view)
results, totalRespondents, err := service.GetSurveyResults(model.DB, letter.ID, true)
if err == nil {
templateData["surveyResults"] = results
templateData["totalRespondents"] = totalRespondents
}
}
// Get user's own responses for highlighting
userResponses, _ := service.GetUserSurveyResponses(model.DB, letter.ID, user.ID)
// Convert to maps for easy lookup in template
userOptionIds := make(map[uint]bool)
userFreeTexts := make(map[uint]string)
for _, resp := range userResponses {
if resp.OptionId != nil {
userOptionIds[*resp.OptionId] = true
}
if resp.FreeTextAnswer != "" {
userFreeTexts[resp.QuestionId] = resp.FreeTextAnswer
}
}
templateData["userOptionIds"] = userOptionIds
templateData["userFreeTexts"] = userFreeTexts
}
}
if isJSON {
templateData["success"] = true
templateData["html"] = string(htmlContent)
c.JSON(http.StatusOK, templateData)
} else {
util.RenderHTMLOK(c, "parental-letter-view-parent.html", templateData)
}
}
// ViewLetterEmployee shows a letter to employees/leaders with read statistics
func ViewLetterEmployee(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Check if user is employee OR admin (with or without location selected)
if !user.IsEmployee() && !user.IsAdmin() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").Preload("DelegatedTo").Preload("Reviewer").Preload("CreatedAsStandInFor").First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// If it's a draft, redirect to edit page
if letter.Draft && service.CanUserEditLetter(model.DB, user, &letter) {
if strings.Contains(c.Request.URL.Path, "global-parental-letters") {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters/"+letterIDStr+"/edit")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters/"+letterIDStr+"/edit")
}
return
}
// If it's pending review and user is the reviewer, redirect to review page
if letter.ReviewStatus == "pending_review" && service.CanUserReviewLetter(user, &letter) {
if strings.Contains(c.Request.URL.Path, "global-parental-letters") {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters/"+letterIDStr+"/review")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters/"+letterIDStr+"/review")
}
return
}
// Mark letter as read for the viewing employee
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
// Get read statistics
read, unread, err := service.GetReadStatistics(model.DB, uint(letterID))
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to get statistics"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Fetch answers from parents if letter allows answers
type ParentAnswer struct {
ParentName string
ParentRole string
Answer string
AnsweredAt time.Time
}
var answers []ParentAnswer
if letter.InteractionType == "answer_possible" || letter.InteractionType == "answer_required" {
var letterReads []model.ParentalLetterRead
model.DB.Preload("User.Roles").Preload("User.Children").
Where("letter_id = ? AND answer IS NOT NULL", letter.ID).
Order("answered_at ASC").
Find(&letterReads)
for _, lr := range letterReads {
if lr.Answer != nil && lr.AnsweredAt != nil {
// Get the first role name, or "Unknown" if no roles
roleName := "Unknown"
if len(lr.User.Roles) > 0 {
roleName = lr.User.Roles[0].Name
}
answers = append(answers, ParentAnswer{
ParentName: lr.User.DisplayNameWithChildren(),
ParentRole: roleName,
Answer: *lr.Answer,
AnsweredAt: *lr.AnsweredAt,
})
}
}
}
// Count answers
var answerCount int64
model.DB.Model(&model.ParentalLetterRead{}).
Where("letter_id = ? AND answer IS NOT NULL", letter.ID).
Count(&answerCount)
// Mark letter responses as viewed if the creator is viewing and there are answers
if letter.CreatedById == user.ID && answerCount > 0 {
now := time.Now()
model.DB.Model(&model.ParentalLetter{}).
Where("id = ?", letter.ID).
Update("answers_last_viewed_at", now)
}
// Format text
htmlContent := util.RenderMarkdown(letter.Text)
// Calculate canEdit: admin uses CanAdminEditLetter, others use CanUserEditLetter
var canEdit bool
if user.IsAdmin() {
canEdit = service.CanAdminEditLetter(model.DB, &letter)
} else {
canEdit = service.CanUserEditLetter(model.DB, user, &letter)
}
// Load attachments
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByLetter(letter.ID)
// Prepare template data
templateData := gin.H{
"lang": langStr,
"user": user,
"letter": letter,
"htmlContent": htmlContent,
"read": read,
"unread": unread,
"answers": answers,
"answerCount": int(answerCount),
"attachments": attachments,
"title": "Parental Letter",
"canEdit": canEdit,
"isAdmin": user.IsAdmin(),
}
// Load poll results if poll type
if letter.InteractionType == "poll_single" || letter.InteractionType == "poll_multi" {
results, totalVoters, err := service.GetPollResults(model.DB, letter.ID, letter.IsAnonymousPoll)
if err == nil {
templateData["pollResults"] = results
templateData["totalVoters"] = totalVoters
}
templateData["hasVotes"] = service.HasPollVotes(model.DB, letter.ID)
}
// Load table data if table type
if letter.InteractionType == "table" {
tableData, err := service.GetTableData(model.DB, letter.ID)
if err == nil {
templateData["tableData"] = tableData
}
templateData["hasEntries"] = service.HasTableEntries(model.DB, letter.ID)
// Check if this is a user-entry style table (no predefined rows)
hasPredefinedRows := service.HasPredefinedRows(model.DB, letter.ID)
templateData["hasPredefinedRows"] = hasPredefinedRows
if !hasPredefinedRows {
// User-entry style table - load all user-created entries
tableEntries, _ := service.GetTableEntriesWithUsers(model.DB, letter.ID)
templateData["tableEntries"] = tableEntries
// Get the columns for display
columns, _ := service.GetTableColumns(model.DB, letter.ID)
templateData["tableColumns"] = columns
}
}
// Load survey results if survey type
if letter.InteractionType == "survey" {
results, totalRespondents, err := service.GetSurveyResults(model.DB, letter.ID, letter.IsAnonymousPoll)
if err == nil {
templateData["surveyResults"] = results
templateData["totalRespondents"] = totalRespondents
}
templateData["hasResponses"] = service.HasSurveyResponses(model.DB, letter.ID)
}
if isJSON {
templateData["success"] = true
templateData["html"] = string(htmlContent)
c.JSON(http.StatusOK, templateData)
} else {
util.RenderHTMLOK(c, "parental-letter-view-employee.html", templateData)
}
}
// getGroupsForLetterForm returns the groups a user can select when creating/editing parental letters.
// For location leads (IsHouseLeader), this includes ALL groups at their assigned locations.
// For group leads, this includes only the groups they are directly assigned to.
func getGroupsForLetterForm(user *model.User) []model.Group {
var groups []model.Group
groupIDSet := make(map[uint]bool)
// First, get groups where user is directly assigned as teacher
// (handles intranet-linked employees)
assignedGroups, _ := user.GetEmployeeCoreTeamGroups(model.DB)
for _, g := range assignedGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
// For location leads, also get ALL groups at their assigned locations
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
var locationGroups []model.Group
model.DB.Where("location_id IN ?", locationIDs).
Preload("Location").
Find(&locationGroups)
for _, g := range locationGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
}
}
return groups
}
// CreateLetterForm shows the form for creating a new letter
func CreateLetterForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
scopes, _ := service.AuthorableScopesForUser(model.DB, user)
// Authorize: must be a lead OR hold at least one active stand-in.
if !user.IsGroupLeader() && !user.IsHouseLeader() && len(scopes.StandInLeads) == 0 {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Determine the action URL based on the request path
actionURL := "/"+langStr+"/parental-letters/create"
backURL := "/"+langStr+"/parental-letters"
if strings.Contains(c.Request.URL.Path, "global-parental-letters") {
actionURL = "/"+langStr+"/global-parental-letters/create"
backURL = "/"+langStr+"/global-parental-letters"
}
// Get the union of groups the user can author for: their own lead-scope
// plus groups granted via active stand-ins.
groups := scopes.Groups
// Get potential employees for delegation (other employees in same groups)
var employees []model.User
if len(groups) > 0 {
var groupIDs []uint
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
model.DB.Distinct().
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Employee").
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Where("group_teachers.group_id IN ?", groupIDs).
Where("users.id != ?", user.ID).
Find(&employees)
}
// Get potential reviewers (other leaders)
reviewers, _ := service.GetPotentialReviewers(model.DB, user)
util.RenderHTMLOK(c, "parental-letter-create.html", gin.H{
"lang": langStr,
"user": user,
"groups": groups,
"employees": employees,
"reviewers": reviewers,
"standInLeads": scopes.StandInLeads,
"locationWideAllowed": scopes.LocationWideAllowed,
"maxAttachments": storage.MaxAttachmentsCount,
"title": "Create Parental Letter",
"actionURL": actionURL,
"backURL": backURL,
})
}
// EditLetterForm shows the form for editing an existing letter
func EditLetterForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").Preload("DelegatedTo").Preload("Reviewer").Preload("CreatedAsStandInFor").First(&letter, letterID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check if user can edit (must be the author or have edit permissions)
if !service.CanUserEditLetter(model.DB, user, &letter) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Determine the action URL based on the request path
actionURL := "/"+langStr+"/parental-letters/"+letterIDStr+"/update"
backURL := "/"+langStr+"/parental-letters"
if strings.Contains(c.Request.URL.Path, "global-parental-letters") {
actionURL = "/"+langStr+"/global-parental-letters/"+letterIDStr+"/update"
backURL = "/"+langStr+"/global-parental-letters"
}
// Get user's groups (includes all groups at location for location leads)
groups := getGroupsForLetterForm(user)
// Get potential employees for delegation
var employees []model.User
if len(groups) > 0 {
var groupIDs []uint
for _, g := range groups {
groupIDs = append(groupIDs, g.ID)
}
model.DB.Distinct().
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Employee").
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Where("group_teachers.group_id IN ?", groupIDs).
Where("users.id != ?", user.ID).
Find(&employees)
}
// Get potential reviewers
reviewers, _ := service.GetPotentialReviewers(model.DB, user)
// Extract pointer values for template
var selectedGroupId uint
if letter.GroupId != nil {
selectedGroupId = *letter.GroupId
}
var selectedDelegateToId uint
if letter.DelegatedToId != nil {
selectedDelegateToId = *letter.DelegatedToId
}
var selectedReviewerId uint
if letter.ReviewerId != nil {
selectedReviewerId = *letter.ReviewerId
}
// Check if current user is the delegated person (not the creator)
isDelegatedUser := letter.DelegatedToId != nil && *letter.DelegatedToId == user.ID
// Load attachments for this letter
fileStorage := storage.NewDBStorage(model.DB)
attachments, _ := fileStorage.GetByLetter(letter.ID)
templateData := gin.H{
"lang": langStr,
"user": user,
"letter": letter,
"groups": groups,
"employees": employees,
"reviewers": reviewers,
"selectedGroupId": selectedGroupId,
"selectedDelegateToId": selectedDelegateToId,
"selectedReviewerId": selectedReviewerId,
"isDelegatedUser": isDelegatedUser,
"attachments": attachments,
"title": "Edit Parental Letter",
"actionURL": actionURL,
"backURL": backURL,
}
// Load poll options if poll type
if letter.InteractionType == "poll_single" || letter.InteractionType == "poll_multi" {
pollOptions, _ := service.GetPollOptions(model.DB, letter.ID)
templateData["pollOptions"] = pollOptions
templateData["hasVotes"] = service.HasPollVotes(model.DB, letter.ID)
}
// Load table structure if table type
if letter.InteractionType == "table" {
columns, _ := service.GetTableColumns(model.DB, letter.ID)
rows, _ := service.GetTableRows(model.DB, letter.ID)
templateData["tableColumns"] = columns
templateData["tableRows"] = rows
templateData["hasEntries"] = service.HasTableEntries(model.DB, letter.ID)
}
// Load survey questions if survey type
if letter.InteractionType == "survey" {
surveyQuestions, _ := service.GetSurveyQuestions(model.DB, letter.ID)
templateData["surveyQuestions"] = surveyQuestions
templateData["hasResponses"] = service.HasSurveyResponses(model.DB, letter.ID)
}
// Add max attachments constant
templateData["maxAttachments"] = storage.MaxAttachmentsCount
util.RenderHTMLOK(c, "parental-letter-edit.html", templateData)
}
// UpdateLetter handles the update of an existing letter
func UpdateLetter(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load existing letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if user can edit
if !service.CanUserEditLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form data (multipart for file uploads)
subject := c.PostForm("subject")
groupIDStr := c.PostForm("group_id")
text := c.PostForm("text")
locationWide := c.PostForm("location_wide") == "true"
delegateToStr := c.PostForm("delegate_to")
reviewerIDStr := c.PostForm("reviewer_id")
interactionType := c.PostForm("interaction_type")
deadlineStr := c.PostForm("deadline")
action := c.PostForm("action") // "draft" or "publish"
isAnonymousPoll := c.PostForm("anonymous_poll") == "true"
hidePollResults := c.PostForm("hide_poll_results") == "true"
// Parse poll/table specific fields
pollOptions := c.PostFormArray("poll_options")
tableColumns := c.PostFormArray("table_columns")
tableRows := c.PostFormArray("table_rows")
// Get uploaded files
fileStorage := storage.NewDBStorage(model.DB)
form, _ := c.MultipartForm()
var uploadedFiles []*multipartFileInfo
if form != nil && form.File["attachments"] != nil {
files := form.File["attachments"]
for _, fileHeader := range files {
// Skip empty file inputs
if fileHeader.Size == 0 {
continue
}
// Open file
file, err := fileHeader.Open()
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
defer file.Close()
// Read content
content, err := io.ReadAll(file)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate file
mimeType, err := storage.ValidateFile(fileHeader.Filename, content)
if err != nil {
errorMsg := "invalid file"
switch err {
case storage.ErrFileTooLarge:
errorMsg = "file too large (max 10 MB)"
case storage.ErrInvalidFileType:
errorMsg = "invalid file type (only PDF, PNG, JPG, GIF allowed)"
}
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
uploadedFiles = append(uploadedFiles, &multipartFileInfo{
Filename: fileHeader.Filename,
MimeType: mimeType,
Content: content,
})
}
}
// Get existing attachment count
existingCount, _ := fileStorage.CountByLetter(letter.ID)
// Check attachment count limit
if int64(len(uploadedFiles))+existingCount > int64(storage.MaxAttachmentsCount) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many attachments (max 5)"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate subject
if strings.TrimSpace(subject) == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "subject is required"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate text OR attachments (at least one required)
hasText := strings.TrimSpace(text) != ""
hasAttachments := len(uploadedFiles) > 0 || existingCount > 0
if !hasText && !hasAttachments {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "please provide either text or at least one attachment"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate interaction type
if interactionType == "" {
interactionType = "informal"
}
validInteractionTypes := []string{"informal", "answer_possible", "answer_required", "poll_single", "poll_multi", "table", "survey"}
isValidInteractionType := false
for _, valid := range validInteractionTypes {
if interactionType == valid {
isValidInteractionType = true
break
}
}
if !isValidInteractionType {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid interaction type"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse and validate survey questions
var surveyQuestions []service.QuestionInput
if interactionType == "survey" {
surveyQuestions = parseSurveyQuestionsFromForm(c)
// Only validate if no responses exist yet
if !service.HasSurveyResponses(model.DB, uint(letterID)) {
if len(surveyQuestions) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "survey requires at least 1 question"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
if len(surveyQuestions) > 10 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "survey allows maximum 10 questions"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
}
// Validate poll options if changing to poll type (and no votes exist yet)
if interactionType == "poll_single" || interactionType == "poll_multi" {
// Filter out empty options
var nonEmptyOptions []string
for _, opt := range pollOptions {
if strings.TrimSpace(opt) != "" {
nonEmptyOptions = append(nonEmptyOptions, opt)
}
}
// Only validate if there are no votes yet (structure can still be changed)
if !service.HasPollVotes(model.DB, uint(letterID)) && len(nonEmptyOptions) < 2 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "poll requires at least 2 options"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
pollOptions = nonEmptyOptions
}
// Validate table structure if changing to table type (and no entries exist yet)
if interactionType == "table" {
var nonEmptyCols, nonEmptyRows []string
for _, col := range tableColumns {
if strings.TrimSpace(col) != "" {
nonEmptyCols = append(nonEmptyCols, col)
}
}
for _, row := range tableRows {
if strings.TrimSpace(row) != "" {
nonEmptyRows = append(nonEmptyRows, row)
}
}
// Only validate if there are no entries yet (structure can still be changed)
if !service.HasTableEntries(model.DB, uint(letterID)) && len(nonEmptyCols) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "table requires at least 1 column"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
tableColumns = nonEmptyCols
tableRows = nonEmptyRows
}
// Parse deadline if provided
var deadline *time.Time
if deadlineStr != "" {
parsedDeadline, err := time.Parse("2006-01-02", deadlineStr)
if err == nil {
deadline = &parsedDeadline
}
}
// Parse group ID
var groupID *uint
if !locationWide && groupIDStr != "" {
parsed, err := strconv.ParseUint(groupIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid group ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
uid := uint(parsed)
groupID = &uid
}
// Check if user is the delegated person (cannot change delegation/reviewer)
isDelegatedUser := letter.DelegatedToId != nil && *letter.DelegatedToId == user.ID
var delegateToID *uint
var reviewerID *uint
if isDelegatedUser {
// Preserve existing delegation and reviewer
delegateToID = letter.DelegatedToId
reviewerID = letter.ReviewerId
} else {
// Parse delegation
if delegateToStr != "" {
parsed, err := strconv.ParseUint(delegateToStr, 10, 32)
if err == nil {
uid := uint(parsed)
delegateToID = &uid
}
}
// Parse reviewer
if reviewerIDStr != "" {
parsed, err := strconv.ParseUint(reviewerIDStr, 10, 32)
if err == nil {
uid := uint(parsed)
reviewerID = &uid
}
}
// Auto-set reviewer if delegating but no reviewer selected
if delegateToID != nil && reviewerID == nil {
reviewerID = &user.ID
}
}
// Determine review status and draft status
reviewStatus := "draft"
isDraftStatus := action == "draft"
if isDelegatedUser && action == "publish" {
// Delegated person is submitting for review
isDraftStatus = false
reviewStatus = "pending_review"
} else if !isDelegatedUser && delegateToID != nil {
// Creator is delegating - keep as draft for delegated person
isDraftStatus = true
reviewStatus = "draft"
} else if reviewerID != nil {
// There's a reviewer but no delegation - goes directly to review
reviewStatus = "pending_review"
} else if action == "publish" {
// No reviewer and no delegation - publishing directly
reviewStatus = "published"
}
// Update letter fields
letter.Subject = subject
letter.Text = text
letter.InteractionType = interactionType
letter.Deadline = deadline
letter.GroupId = groupID
letter.Draft = isDraftStatus
letter.ReviewStatus = reviewStatus
letter.DelegatedToId = delegateToID
letter.ReviewerId = reviewerID
letter.LastEditedById = &user.ID
letter.IsAnonymousPoll = isAnonymousPoll
letter.HidePollResults = hidePollResults
now := time.Now()
letter.EditedAt = &now
if action == "publish" && reviewerID == nil {
letter.PublishedAt = &now
}
err = model.DB.Save(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Update poll options if poll type and no votes exist yet
if (interactionType == "poll_single" || interactionType == "poll_multi") && len(pollOptions) > 0 {
if !service.HasPollVotes(model.DB, letter.ID) {
// Delete existing options and create new ones
if err := service.DeletePollOptions(model.DB, letter.ID); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update poll options"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if err := service.CreatePollOptions(model.DB, letter.ID, pollOptions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create poll options"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
}
// Update table structure if table type and no entries exist yet
if interactionType == "table" && len(tableColumns) > 0 {
if !service.HasTableEntries(model.DB, letter.ID) {
// Delete existing structure and create new one
if err := service.DeleteTableStructure(model.DB, letter.ID); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update table structure"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if err := service.CreateTableStructure(model.DB, letter.ID, tableColumns, tableRows); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create table structure"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
}
// Update survey questions if survey type and no responses exist yet
if interactionType == "survey" && len(surveyQuestions) > 0 {
if !service.HasSurveyResponses(model.DB, letter.ID) {
// Delete existing questions and create new ones
if err := service.DeleteSurveyQuestions(model.DB, letter.ID); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to update survey questions"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if err := service.CreateSurveyQuestions(model.DB, letter.ID, surveyQuestions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create survey questions"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
}
// Store new attachments
if len(uploadedFiles) > 0 {
for _, fileInfo := range uploadedFiles {
_, err := fileStorage.Store(letter.ID, fileInfo.Filename, fileInfo.MimeType, fileInfo.Content)
if err != nil {
// Log error but continue - letter is already updated
continue
}
}
}
// If published immediately (no reviewer and not from delegated person), notify parents
if action == "publish" && reviewerID == nil && !isDelegatedUser {
service.NotifyParentsOfLetter(model.DB, &letter)
service.NotifyLetterPublished(model.DB, &letter)
}
// If there's a reviewer and submitting (not saving draft), notify reviewer
if reviewerID != nil && action == "publish" {
service.NotifyReviewerOfLetter(model.DB, &letter)
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letter": letter,
})
} else {
// Redirect back to list
if user.IsHouseLeader() {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters")
}
}
}
// ReviewLetterForm shows the review interface for a pending letter
func ReviewLetterForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.Preload("Group").Preload("Location").Preload("CreatedBy").Preload("DelegatedTo").Preload("Reviewer").Preload("CreatedAsStandInFor").First(&letter, letterID).Error
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
return
}
// Check if user can review (must be the assigned reviewer)
if !service.CanUserReviewLetter(user, &letter) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
// Determine the action URL based on the request path
actionURL := "/"+langStr+"/parental-letters/"+letterIDStr+"/review"
backURL := "/"+langStr+"/parental-letters"
if strings.Contains(c.Request.URL.Path, "global-parental-letters") {
actionURL = "/"+langStr+"/global-parental-letters/"+letterIDStr+"/review"
backURL = "/"+langStr+"/global-parental-letters"
}
// Format text for display
htmlContent := util.RenderMarkdown(letter.Text)
util.RenderHTMLOK(c, "parental-letter-review.html", gin.H{
"lang": langStr,
"user": user,
"letter": letter,
"htmlContent": htmlContent,
"title": "Review Parental Letter",
"actionURL": actionURL,
"backURL": backURL,
})
}
// ReviewLetter handles approve/reject actions for a letter
func ReviewLetter(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load existing letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if user can review
if !service.CanUserReviewLetter(user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form data
action := c.PostForm("action") // "approve" or "reject"
comments := c.PostForm("review_comments")
now := time.Now()
if action == "approve" {
// Approve and publish the letter
letter.ReviewStatus = "published"
letter.Draft = false
letter.PublishedAt = &now
letter.ReviewedAt = &now
if comments != "" {
letter.ReviewComments = &comments
}
err = model.DB.Save(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to approve letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Notify parents
service.NotifyParentsOfLetter(model.DB, &letter)
service.NotifyLetterPublished(model.DB, &letter)
// TODO: Notify the original author that letter was approved
} else if action == "reject" {
// Reject and send back to draft with comments
if comments == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "comments required for rejection"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
letter.ReviewStatus = "draft"
letter.Draft = true
letter.ReviewedAt = &now
letter.ReviewComments = &comments
err = model.DB.Save(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to reject letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// TODO: Notify the original author that letter was rejected with comments
} else {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid action"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letter": letter,
})
} else {
// Redirect back to list
if user.IsHouseLeader() {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters")
}
}
}
// DeleteLetter soft deletes a draft letter
func DeleteLetter(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load existing letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check delete permissions:
// - Non-admin users can only delete drafts they can edit
// - Admins can delete drafts and unread published letters (using CanAdminEditLetter)
var canDelete bool
if user.IsAdmin() {
canDelete = service.CanAdminEditLetter(model.DB, &letter)
} else {
canDelete = letter.Draft && service.CanUserEditLetter(model.DB, user, &letter)
}
if !canDelete {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "cannot delete this letter"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Soft delete the letter
err = model.DB.Delete(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to delete letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "letter deleted",
})
} else {
// Redirect back to list
if user.IsHouseLeader() {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters")
}
}
}
// CreateLetter handles the submission of a new letter
// Supports multi-group selection: when multiple groups are selected, creates one letter per group
func CreateLetter(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Only GroupLeaders and LocationLeaders can create letters
if !user.IsGroupLeader() && !user.IsHouseLeader() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form data (multipart for file uploads)
subject := c.PostForm("subject")
text := c.PostForm("text")
locationWide := c.PostForm("location_wide") == "true"
delegateToStr := c.PostForm("delegate_to")
reviewerIDStr := c.PostForm("reviewer_id")
interactionType := c.PostForm("interaction_type")
deadlineStr := c.PostForm("deadline")
action := c.PostForm("action") // "draft" or "publish"
// Parse multiple group IDs (new multi-select format)
groupIDStrs := c.PostFormArray("group_ids")
// Parse attachment_ids from AJAX-uploaded attachments
attachmentIDStrs := c.PostFormArray("attachment_ids")
var attachmentIDs []uint
for _, idStr := range attachmentIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err == nil {
attachmentIDs = append(attachmentIDs, uint(parsed))
}
}
// Get uploaded files
form, _ := c.MultipartForm()
var uploadedFiles []*multipartFileInfo
if form != nil && form.File["attachments"] != nil {
files := form.File["attachments"]
for _, fileHeader := range files {
// Skip empty file inputs
if fileHeader.Size == 0 {
continue
}
// Open file
file, err := fileHeader.Open()
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
defer file.Close()
// Read content
content, err := io.ReadAll(file)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate file
mimeType, err := storage.ValidateFile(fileHeader.Filename, content)
if err != nil {
errorMsg := "invalid file"
switch err {
case storage.ErrFileTooLarge:
errorMsg = "file too large (max 10 MB)"
case storage.ErrInvalidFileType:
errorMsg = "invalid file type (only PDF, PNG, JPG, GIF allowed)"
}
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
uploadedFiles = append(uploadedFiles, &multipartFileInfo{
Filename: fileHeader.Filename,
MimeType: mimeType,
Content: content,
})
}
}
// Check attachment count limit (includes both AJAX uploads and multipart uploads)
totalAttachmentCount := len(uploadedFiles) + len(attachmentIDs)
if totalAttachmentCount > storage.MaxAttachmentsCount {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many attachments (max 5)"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate subject
if strings.TrimSpace(subject) == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "subject is required"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate text OR attachments (at least one required)
hasText := strings.TrimSpace(text) != ""
hasAttachments := len(uploadedFiles) > 0 || len(attachmentIDs) > 0
if !hasText && !hasAttachments {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "please provide either text or at least one attachment"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Validate interaction type
if interactionType == "" {
interactionType = "informal"
}
validInteractionTypes := []string{"informal", "answer_possible", "answer_required", "poll_single", "poll_multi", "table", "survey"}
isValidInteractionType := false
for _, valid := range validInteractionTypes {
if interactionType == valid {
isValidInteractionType = true
break
}
}
if !isValidInteractionType {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid interaction type"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse poll/table specific fields
pollOptions := c.PostFormArray("poll_options")
isAnonymousPoll := c.PostForm("anonymous_poll") == "true"
hidePollResults := c.PostForm("hide_poll_results") == "true"
tableColumns := c.PostFormArray("table_columns")
tableRows := c.PostFormArray("table_rows")
// Parse survey questions
var surveyQuestions []service.QuestionInput
if interactionType == "survey" {
surveyQuestions = parseSurveyQuestionsFromForm(c)
if len(surveyQuestions) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "survey requires at least 1 question"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
if len(surveyQuestions) > 10 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "survey allows maximum 10 questions"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Validate poll options if poll type
if (interactionType == "poll_single" || interactionType == "poll_multi") && len(pollOptions) < 2 {
// Filter out empty options
var nonEmptyOptions []string
for _, opt := range pollOptions {
if strings.TrimSpace(opt) != "" {
nonEmptyOptions = append(nonEmptyOptions, opt)
}
}
if len(nonEmptyOptions) < 2 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "poll requires at least 2 options"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
pollOptions = nonEmptyOptions
}
// Validate table structure if table type
// Tables require at least 1 column, but rows are optional (parents can add them)
if interactionType == "table" {
var nonEmptyCols, nonEmptyRows []string
for _, col := range tableColumns {
if strings.TrimSpace(col) != "" {
nonEmptyCols = append(nonEmptyCols, col)
}
}
for _, row := range tableRows {
if strings.TrimSpace(row) != "" {
nonEmptyRows = append(nonEmptyRows, row)
}
}
if len(nonEmptyCols) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "table requires at least 1 column"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
tableColumns = nonEmptyCols
tableRows = nonEmptyRows
}
// Parse deadline if provided
var deadline *time.Time
if deadlineStr != "" {
parsedDeadline, err := time.Parse("2006-01-02", deadlineStr)
if err == nil {
deadline = &parsedDeadline
}
}
// Parse group IDs from multi-select
var groupIDs []uint
if !locationWide && len(groupIDStrs) > 0 {
for _, idStr := range groupIDStrs {
parsed, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid group ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
groupIDs = append(groupIDs, uint(parsed))
}
}
// Validate that we have either location-wide or at least one group selected
if !locationWide && len(groupIDs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "please select at least one group or choose location-wide"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get location ID - for multi-group, use first group's location
// (all groups should be at the same location for location leads)
var locationID uint
if len(groupIDs) > 0 {
var group model.Group
model.DB.First(&group, groupIDs[0])
locationID = group.LocationId
} else {
// Location-wide letter - get first group's location
// (handles intranet-linked employees)
groups, _ := user.GetEmployeeCoreTeamGroups(model.DB)
if len(groups) > 0 {
locationID = groups[0].LocationId
}
}
// Parse delegation
var delegateToID *uint
if delegateToStr != "" {
parsed, err := strconv.ParseUint(delegateToStr, 10, 32)
if err == nil {
uid := uint(parsed)
delegateToID = &uid
}
}
// Parse reviewer
var reviewerID *uint
if reviewerIDStr != "" {
parsed, err := strconv.ParseUint(reviewerIDStr, 10, 32)
if err == nil {
uid := uint(parsed)
reviewerID = &uid
}
}
// Auto-set reviewer if delegating but no reviewer selected
if delegateToID != nil && reviewerID == nil {
reviewerID = &user.ID
}
// Determine review status and draft status
reviewStatus := "draft"
isDraft := action == "draft"
if delegateToID != nil {
// If delegating, always keep as draft until delegated person finishes
isDraft = true
reviewStatus = "draft"
} else if reviewerID != nil {
// If there's a reviewer but no delegation, it goes directly to review
reviewStatus = "pending_review"
} else if action == "publish" {
// No reviewer and no delegation - publishing directly
reviewStatus = "published"
}
// Determine publish time
var publishedAt *time.Time
if action == "publish" && reviewerID == nil {
now := c.Request.Context().Value("now")
if now != nil {
nowTime := now.(time.Time)
publishedAt = &nowTime
} else {
nowTime := time.Now()
publishedAt = &nowTime
}
}
// Create letters - one per selected group, or one location-wide letter
var createdLetters []model.ParentalLetter
if locationWide {
// Check permissions
ok, standInFor := service.AuthorizeCreateLetter(model.DB, user, nil, &locationID)
if !ok {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "no permission to create letter for this location"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
var standInForID *uint
if standInFor != nil {
id := standInFor.ID
standInForID = &id
}
// Single location-wide letter (GroupId = nil)
letter := model.ParentalLetter{
CreatedById: user.ID,
Subject: subject,
LocationId: locationID,
GroupId: nil, // nil indicates location-wide
Text: text,
InteractionType: interactionType,
Deadline: deadline,
Draft: isDraft,
ReviewStatus: reviewStatus,
DelegatedToId: delegateToID,
ReviewerId: reviewerID,
PublishedAt: publishedAt,
IsAnonymousPoll: isAnonymousPoll,
HidePollResults: hidePollResults,
CreatedAsStandInForID: standInForID,
}
err := model.DB.Create(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Create poll options if poll type
if interactionType == "poll_single" || interactionType == "poll_multi" {
if err := service.CreatePollOptions(model.DB, letter.ID, pollOptions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create poll options"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Create table structure if table type
if interactionType == "table" {
if err := service.CreateTableStructure(model.DB, letter.ID, tableColumns, tableRows); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create table structure"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Create survey questions if survey type
if interactionType == "survey" {
if err := service.CreateSurveyQuestions(model.DB, letter.ID, surveyQuestions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create survey questions"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
createdLetters = append(createdLetters, letter)
} else {
// Create one letter per selected group
for _, gid := range groupIDs {
groupID := gid // Create a copy for the pointer
// Check permissions for each group
ok, standInFor := service.AuthorizeCreateLetter(model.DB, user, &groupID, &locationID)
if !ok {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "no permission to create letter for this group"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
var standInForID *uint
if standInFor != nil {
id := standInFor.ID
standInForID = &id
}
letter := model.ParentalLetter{
CreatedById: user.ID,
Subject: subject,
LocationId: locationID,
GroupId: &groupID,
Text: text,
InteractionType: interactionType,
Deadline: deadline,
Draft: isDraft,
ReviewStatus: reviewStatus,
DelegatedToId: delegateToID,
ReviewerId: reviewerID,
PublishedAt: publishedAt,
IsAnonymousPoll: isAnonymousPoll,
HidePollResults: hidePollResults,
CreatedAsStandInForID: standInForID,
}
err := model.DB.Create(&letter).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create letter"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Create poll options if poll type
if interactionType == "poll_single" || interactionType == "poll_multi" {
if err := service.CreatePollOptions(model.DB, letter.ID, pollOptions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create poll options"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Create table structure if table type
if interactionType == "table" {
if err := service.CreateTableStructure(model.DB, letter.ID, tableColumns, tableRows); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create table structure"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
// Create survey questions if survey type
if interactionType == "survey" {
if err := service.CreateSurveyQuestions(model.DB, letter.ID, surveyQuestions); err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to create survey questions"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
}
createdLetters = append(createdLetters, letter)
}
}
// Store attachments for each created letter
fileStorage := storage.NewDBStorage(model.DB)
for i := range createdLetters {
letter := &createdLetters[i]
// Store multipart-uploaded files
for _, fileInfo := range uploadedFiles {
_, err := fileStorage.Store(letter.ID, fileInfo.Filename, fileInfo.MimeType, fileInfo.Content)
if err != nil {
// Log error but continue - letter is already created
// Attachments can be added later via edit
continue
}
}
// Associate AJAX-uploaded orphan attachments with the first letter only
// (for multi-group letters, attachments are only associated with the first one)
if i == 0 && len(attachmentIDs) > 0 {
fileStorage.AssociateOrphans(attachmentIDs, letter.ID)
}
}
// Notify for each created letter
for i := range createdLetters {
letter := &createdLetters[i]
// If published immediately (no reviewer and no delegation), notify parents
if action == "publish" && reviewerID == nil && delegateToID == nil {
service.NotifyParentsOfLetter(model.DB, letter)
service.NotifyLetterPublished(model.DB, letter)
}
// If there's a reviewer and no delegation (direct to review), notify reviewer
if reviewerID != nil && delegateToID == nil {
service.NotifyReviewerOfLetter(model.DB, letter)
}
}
if isJSON {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"letters": createdLetters,
"count": len(createdLetters),
})
} else {
// Redirect back to list
if user.IsHouseLeader() {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters")
}
}
}
// ParentSubmitLetterAnswer handles the submission of an answer to a parental letter by a parent
func ParentSubmitLetterAnswer(c *gin.Context) {
// Get language from context
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found in context",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Wippidu - requested data not found...",
"lang": langStr,
"user": model.NewDummyUser(),
})
}
return
}
user := userInterface.(*model.User)
// Check if user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "Only parents can submit answers",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Get letter ID from URL parameter
letterIDParam := c.Param("id")
letterID, err := strconv.ParseUint(letterIDParam, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Invalid letter ID",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Invalid letter ID",
"lang": langStr,
"user": user,
})
}
return
}
// Get answer from form
answer := strings.TrimSpace(c.PostForm("answer"))
if answer == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "Answer text is required",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "Answer text is required",
"lang": langStr,
"user": user,
})
}
return
}
// Fetch the letter
var letter model.ParentalLetter
if err := model.DB.First(&letter, uint(letterID)).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Letter not found",
},
})
} else {
util.RenderHTML(c, http.StatusNotFound, "error.html", gin.H{
"title": "Error",
"error": "Letter not found",
"lang": langStr,
"user": user,
})
}
return
}
// Scope check (audit #464): centralised in service.CanUserViewLetter.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "You don't have access to this letter",
},
})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Wippidu - Access Denied",
"lang": langStr,
"user": user,
})
}
return
}
// Check if letter allows answers
if letter.InteractionType != "answer_possible" && letter.InteractionType != "answer_required" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "This letter does not allow answers",
},
})
} else {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"error": "This letter does not allow answers",
"lang": langStr,
"user": user,
})
}
return
}
// Find or create ParentalLetterRead record
var letterRead model.ParentalLetterRead
err = model.DB.Where("letter_id = ? AND user_id = ?", letter.ID, user.ID).First(&letterRead).Error
if err != nil {
// Create new read record with answer
now := time.Now()
letterRead = model.ParentalLetterRead{
LetterId: letter.ID,
UserId: user.ID,
ReadAt: &now,
Answer: &answer,
AnsweredAt: &now,
}
if err := model.DB.Create(&letterRead).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to save answer",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to save answer",
"lang": langStr,
"user": user,
})
}
return
}
} else {
// Update existing record with answer
now := time.Now()
letterRead.Answer = &answer
letterRead.AnsweredAt = &now
if err := model.DB.Save(&letterRead).Error; err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to save answer",
},
})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"error": "Failed to save answer",
"lang": langStr,
"user": user,
})
}
return
}
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"letterRead": letterRead,
})
return
}
// Redirect back to letter detail (parent view)
c.Redirect(http.StatusSeeOther, "/"+langStr+"/parentalletters/"+letterIDParam)
}
// ParentSubmitPollVote handles poll vote submission from parents
func ParentSubmitPollVote(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "only parents can vote"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify it's a poll
if letter.InteractionType != "poll_single" && letter.InteractionType != "poll_multi" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "this letter is not a poll"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): centralised in service.CanUserViewLetter.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if already voted
if service.HasUserVoted(model.DB, uint(letterID), user.ID) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "already voted"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
return
}
// Get selected option IDs
optionIDStrs := c.PostFormArray("option_ids")
if len(optionIDStrs) == 0 {
// Try single option format
singleOption := c.PostForm("option_id")
if singleOption != "" {
optionIDStrs = []string{singleOption}
}
}
if len(optionIDStrs) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "no options selected"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
var optionIDs []uint
for _, idStr := range optionIDStrs {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid option ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
optionIDs = append(optionIDs, uint(id))
}
// Submit vote
isSingleChoice := letter.InteractionType == "poll_single"
err = service.SubmitPollVote(model.DB, uint(letterID), user.ID, optionIDs, isSingleChoice)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark letter as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ParentSubmitTableCell handles table cell submission from parents
func ParentSubmitTableCell(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "only parents can fill table"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify it's a table
if letter.InteractionType != "table" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "this letter is not a table"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): centralised in service.CanUserViewLetter.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get cell data
rowIDStr := c.PostForm("row_id")
columnIDStr := c.PostForm("column_id")
value := c.PostForm("value")
rowID, err := strconv.ParseUint(rowIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid row ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
columnID, err := strconv.ParseUint(columnIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid column ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Submit cell
err = service.SubmitTableCell(model.DB, uint(letterID), uint(rowID), uint(columnID), user.ID, value)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark letter as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ParentSubmitTableCells handles multiple table cell submissions from parents (single save button)
func ParentSubmitTableCells(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "only parents can fill table"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify it's a table
if letter.InteractionType != "table" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "this letter is not a table"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): centralised in service.CanUserViewLetter.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse form to get all cell_<rowId>_<colId> fields
if err := c.Request.ParseForm(); err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to parse form"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Collect all cells to submit
for key, values := range c.Request.PostForm {
if !strings.HasPrefix(key, "cell_") {
continue
}
// Parse cell_<rowId>_<colId>
parts := strings.Split(key, "_")
if len(parts) != 3 {
continue
}
rowID, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
continue
}
columnID, err := strconv.ParseUint(parts[2], 10, 32)
if err != nil {
continue
}
value := ""
if len(values) > 0 {
value = values[0]
}
// Submit cell (ignoring errors for individual cells)
service.SubmitTableCell(model.DB, uint(letterID), uint(rowID), uint(columnID), user.ID, value)
}
// Mark letter as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// parseSurveyQuestionsFromForm parses survey questions from form data
// Expected format:
// - question_text_0, question_text_1, ... (question texts)
// - question_type_0, question_type_1, ... (question types: single_choice, multi_choice, free_text)
// - question_options_0 (array), question_options_1 (array), ... (options for choice questions)
func parseSurveyQuestionsFromForm(c *gin.Context) []service.QuestionInput {
var questions []service.QuestionInput
// Parse up to 10 questions
for i := 0; i < 10; i++ {
text := c.PostForm("question_text_" + strconv.Itoa(i))
qType := c.PostForm("question_type_" + strconv.Itoa(i))
if text == "" || qType == "" {
continue
}
// Validate question type
if qType != "single_choice" && qType != "multi_choice" && qType != "free_text" {
continue
}
q := service.QuestionInput{
Text: strings.TrimSpace(text),
QuestionType: qType,
}
// Get options for choice questions
if qType != "free_text" {
options := c.PostFormArray("question_options_" + strconv.Itoa(i))
for _, opt := range options {
if strings.TrimSpace(opt) != "" {
q.Options = append(q.Options, strings.TrimSpace(opt))
}
}
}
questions = append(questions, q)
}
return questions
}
// ParentSubmitSurvey handles survey submission from parents
func ParentSubmitSurvey(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "only parents can submit survey"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get letter ID from URL
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify it's a survey
if letter.InteractionType != "survey" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "this letter is not a survey"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): centralised in service.CanUserViewLetter.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if already submitted
if service.HasUserRespondedToSurvey(model.DB, uint(letterID), user.ID) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "already submitted"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
return
}
// Get survey questions
questions, err := service.GetSurveyQuestions(model.DB, uint(letterID))
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to load questions"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse responses from form
var responses []service.ResponseInput
for _, q := range questions {
resp := service.ResponseInput{
QuestionId: q.ID,
}
if q.QuestionType == "free_text" {
// Get free text answer
resp.FreeTextAnswer = c.PostForm("response_" + strconv.FormatUint(uint64(q.ID), 10) + "_text")
} else if q.QuestionType == "single_choice" {
// Get single selected option
singleOption := c.PostForm("response_" + strconv.FormatUint(uint64(q.ID), 10))
if singleOption != "" {
optID, err := strconv.ParseUint(singleOption, 10, 32)
if err == nil {
resp.OptionIds = append(resp.OptionIds, uint(optID))
}
}
} else {
// Get multi-selected options
optionStrs := c.PostFormArray("response_" + strconv.FormatUint(uint64(q.ID), 10) + "_multi")
for _, optStr := range optionStrs {
optID, err := strconv.ParseUint(optStr, 10, 32)
if err == nil {
resp.OptionIds = append(resp.OptionIds, uint(optID))
}
}
}
responses = append(responses, resp)
}
// Submit responses
err = service.SubmitSurveyResponses(model.DB, uint(letterID), user.ID, responses)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark letter as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ParentAddTableEntry handles adding a new table entry (for tables without predefined rows)
func ParentAddTableEntry(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/login")
}
return
}
user := userInterface.(*model.User)
// Verify user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get the letter
var letter model.ParentalLetter
err = model.DB.First(&letter, letterID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): pre-fix the handler verified the user
// is a parent and the letter is a table — but not that the parent
// has a child in the letter's group/location. A parent could
// inject rows into any letter by ID-guessing.
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify letter is a table type
if letter.InteractionType != "table" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "letter is not a table type"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Verify table has no predefined rows (is a user-entry style table)
if service.HasPredefinedRows(model.DB, uint(letterID)) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "this table has predefined rows, use table-cells endpoint instead"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get columns for this table
columns, err := service.GetTableColumns(model.DB, uint(letterID))
if err != nil || len(columns) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "table has no columns"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse cell values from form (format: cell_<columnId>)
cellValues := make(map[uint]string)
for _, col := range columns {
value := strings.TrimSpace(c.PostForm("cell_" + strconv.FormatUint(uint64(col.ID), 10)))
if value != "" {
cellValues[col.ID] = value
}
}
// Ensure at least one value is provided
if len(cellValues) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "at least one cell value is required"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Create the entry
err = service.CreateTableEntry(model.DB, uint(letterID), user.ID, cellValues)
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Mark letter as read
service.MarkLetterAsRead(model.DB, uint(letterID), user.ID)
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ParentUpdateTableEntry handles updating an existing table entry
func ParentUpdateTableEntry(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/login")
}
return
}
user := userInterface.(*model.User)
// Verify user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
rowIDStr := c.Param("rowId")
rowID, err := strconv.ParseUint(rowIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid row ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): the original handler had no
// letter-scope check at all — it relied on UpdateTableEntry's
// row-ownership check to prevent cross-parent tampering, but a
// parent could still mutate rows on letters their group/location
// isn't targeted by. CanUserViewLetter closes that surface.
var letter model.ParentalLetter
if err := model.DB.First(&letter, letterID).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Get columns for this table
columns, err := service.GetTableColumns(model.DB, uint(letterID))
if err != nil || len(columns) == 0 {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "table has no columns"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Parse cell values from form (format: cell_<columnId>)
cellValues := make(map[uint]string)
for _, col := range columns {
value := strings.TrimSpace(c.PostForm("cell_" + strconv.FormatUint(uint64(col.ID), 10)))
cellValues[col.ID] = value // Allow empty to clear values
}
// Update the entry
err = service.UpdateTableEntry(model.DB, uint(letterID), uint(rowID), user.ID, cellValues)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ParentDeleteTableEntry handles deleting a table entry
func ParentDeleteTableEntry(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/login")
}
return
}
user := userInterface.(*model.User)
// Verify user is a parent
if !user.IsParent() {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
letterIDStr := c.Param("id")
letterID, err := strconv.ParseUint(letterIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid letter ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
rowIDStr := c.Param("rowId")
rowID, err := strconv.ParseUint(rowIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid row ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Scope check (audit #464): pre-fix this handler had no
// letter-scope check; DeleteTableEntry's row-ownership filter
// scoped the destructive action to the caller's own rows, but the
// caller still needed to be in the letter's audience.
var letter model.ParentalLetter
if err := model.DB.First(&letter, letterID).Error; err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
if !service.CanUserViewLetter(model.DB, user, &letter) {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Delete the entry
err = service.DeleteTableEntry(model.DB, uint(letterID), uint(rowID), user.ID)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{"success": true})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parentalletters/"+letterIDStr)
}
}
// ServeAttachment serves an attachment file for download
func ServeAttachment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get attachment ID from URL
attachmentIDStr := c.Param("id")
attachmentID, err := strconv.ParseUint(attachmentIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid attachment ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load attachment with letter (if associated)
var attachment model.Attachment
err = model.DB.Preload("ParentalLetter").First(&attachment, attachmentID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "attachment not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check access
hasAccess := false
// Orphan attachment (not yet associated with a letter) - only uploader can access
if attachment.ParentalLetterId == nil {
hasAccess = attachment.UploadedById != nil && *attachment.UploadedById == user.ID
} else {
// CanUserViewLetter handles all roles (admin / author / delegatee
// / reviewer / employee / parent) in one place — audit #464 had
// flagged the parent branch as a separate hand-rolled check.
letter := attachment.ParentalLetter
hasAccess = service.CanUserViewLetter(model.DB, user, &letter)
}
if !hasAccess {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Set headers for download
c.Header("Content-Type", attachment.MimeType)
c.Header("Content-Disposition", "attachment; filename=\""+attachment.Filename+"\"")
c.Header("Content-Length", strconv.FormatInt(attachment.Size, 10))
// Serve the content
c.Data(http.StatusOK, attachment.MimeType, attachment.Content)
}
// DeleteAttachment removes an attachment from a letter
func DeleteAttachment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
isJSON := shouldReturnJSON(c)
userInterface, ok := c.Get("User")
if !ok {
if isJSON {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
}
return
}
user := userInterface.(*model.User)
// Get attachment ID from URL
attachmentIDStr := c.Param("id")
attachmentID, err := strconv.ParseUint(attachmentIDStr, 10, 32)
if err != nil {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid attachment ID"})
} else {
util.RenderHTML(c, http.StatusBadRequest, "400.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Load attachment with letter (if associated)
var attachment model.Attachment
err = model.DB.Preload("ParentalLetter").First(&attachment, attachmentID).Error
if err != nil {
if isJSON {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "attachment not found"})
} else {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Check if user can delete this attachment
canDelete := false
// Orphan attachment - only uploader can delete
if attachment.ParentalLetterId == nil {
canDelete = attachment.UploadedById != nil && *attachment.UploadedById == user.ID
} else {
// Check if user can edit this letter
letter := attachment.ParentalLetter
if user.IsAdmin() {
canDelete = service.CanAdminEditLetter(model.DB, &letter)
} else {
canDelete = service.CanUserEditLetter(model.DB, user, &letter)
}
}
if !canDelete {
if isJSON {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
} else {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
}
return
}
// Delete the attachment
err = model.DB.Delete(&attachment).Error
if err != nil {
if isJSON {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to delete attachment"})
} else {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user})
}
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "attachment deleted",
})
} else {
// Redirect back to edit page (only applicable when attachment is associated with a letter)
if attachment.ParentalLetterId != nil {
letter := attachment.ParentalLetter
if user.IsHouseLeader() {
c.Redirect(http.StatusFound, "/"+langStr+"/global-parental-letters/"+strconv.FormatUint(uint64(letter.ID), 10)+"/edit")
} else {
c.Redirect(http.StatusFound, "/"+langStr+"/parental-letters/"+strconv.FormatUint(uint64(letter.ID), 10)+"/edit")
}
} else {
// For orphan attachments deleted via AJAX, just return success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "attachment deleted"})
}
}
}
// UploadAttachment handles AJAX file upload for parental letters
// POST /attachments/upload
// Returns JSON: { success: true, attachment: { id, filename, mimeType, size } }
func UploadAttachment(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "unauthorized"})
return
}
user := userInterface.(*model.User)
// Only GroupLeaders and LocationLeaders can upload attachments
if !user.IsGroupLeader() && !user.IsHouseLeader() {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
// Get optional letter_id (for editing existing letters)
letterIDStr := c.PostForm("letter_id")
var letterID *uint
if letterIDStr != "" {
parsed, err := strconv.ParseUint(letterIDStr, 10, 32)
if err == nil {
uid := uint(parsed)
letterID = &uid
// Verify user can edit this letter
var letter model.ParentalLetter
if err := model.DB.First(&letter, uid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "letter not found"})
return
}
if !service.CanUserEditLetter(model.DB, user, &letter) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "forbidden"})
return
}
}
}
// Get uploaded file
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "no file uploaded"})
return
}
// Skip empty files
if fileHeader.Size == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "empty file"})
return
}
// Open file
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
return
}
defer file.Close()
// Read content
content, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "failed to read uploaded file"})
return
}
// Validate file
mimeType, err := storage.ValidateFile(fileHeader.Filename, content)
if err != nil {
errorMsg := "invalid file"
switch err {
case storage.ErrFileTooLarge:
errorMsg = "file too large (max 10 MB)"
case storage.ErrInvalidFileType:
errorMsg = "invalid file type (only PDF, PNG, JPG, GIF allowed)"
}
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
return
}
fileStorage := storage.NewDBStorage(model.DB)
var attachmentID uint
if letterID != nil {
// Store directly with letter ID
attachmentID, err = fileStorage.Store(*letterID, fileHeader.Filename, mimeType, content)
} else {
// Store as orphan (will be associated when letter is saved)
attachmentID, err = fileStorage.StoreOrphan(user.ID, fileHeader.Filename, mimeType, content)
}
if err != nil {
errorMsg := "failed to store file"
switch err {
case storage.ErrTooManyFiles:
errorMsg = "too many attachments (max 5)"
case storage.ErrTotalSizeExceeded:
errorMsg = "total attachment size exceeded (max 30 MB)"
}
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errorMsg})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"attachment": gin.H{
"id": attachmentID,
"filename": fileHeader.Filename,
"mimeType": mimeType,
"size": fileHeader.Size,
},
})
}
package controller
import (
"log/slog"
"net/http"
"strconv"
"time"
"wippidu_app_backend/internal/botprotection"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// RegistrationForm is the form data for registration
type RegistrationForm struct {
FirstName string `form:"first_name" binding:"required"`
LastName string `form:"last_name" binding:"required"`
Email string `form:"email" binding:"required,email"`
RequestType string `form:"request_type" binding:"required"`
ChildFirstName string `form:"child_first_name"`
ChildLastName string `form:"child_last_name"`
ChildBirthday string `form:"child_birthday"`
LocationID uint `form:"location_id" binding:"required"`
GroupID uint `form:"group_id"`
RelationshipRole string `form:"relationship_role"`
Relationship string `form:"relationship"` // Honeypot field
InvitationCode string `form:"invitation_code"` // Invitation code for pre-filled parent registration
EmployeeInvitationCode string `form:"employee_invitation_code"` // Invitation code for employee registration
BotCheck string `form:"_botcheck"` // Bot protection challenge token
}
// ShowRegistrationForm displays the public registration form
func ShowRegistrationForm(c *gin.Context) {
lang := c.DefaultQuery("lang", "de")
// Check for rate limit error from redirect
rateLimitError := c.Query("error")
retryAfter := c.Query("retry_after")
regService := service.NewRegistrationService(model.DB)
locations, err := regService.GetAllLocations()
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"lang": lang,
"error": "Failed to load locations",
})
return
}
relationshipRoles := service.GetAllRelationshipRoles()
// Check for parent invitation code
invitationCode := c.Query("code")
var invitation *model.InvitationCode
var invitationError string
if invitationCode != "" {
invService := service.NewInvitationService(model.DB)
inv, err := invService.GetInvitationByCode(invitationCode)
if err != nil {
invitationError = "invitation.error.not_found"
} else if !inv.IsValid() {
if inv.IsUsed() {
invitationError = "invitation.error.already_used"
} else if inv.IsRevoked() {
invitationError = "invitation.error.revoked"
} else if inv.IsExpired() {
invitationError = "invitation.error.expired"
} else {
invitationError = "invitation.error.invalid"
}
} else {
invitation = inv
}
}
// Check for employee invitation code
employeeInvitationCode := c.Query("empcode")
var employeeInvitation *model.EmployeeInvitationCode
var employeeInvitationError string
if employeeInvitationCode != "" {
empInvService := service.NewEmployeeInvitationService(model.DB)
empInv, err := empInvService.GetEmployeeInvitationByCode(employeeInvitationCode)
if err != nil {
employeeInvitationError = "employee_invitation.error.not_found"
} else if !empInv.IsValid() {
if empInv.IsUsed() {
employeeInvitationError = "employee_invitation.error.already_used"
} else if empInv.IsRevoked() {
employeeInvitationError = "employee_invitation.error.revoked"
} else if empInv.IsExpired() {
employeeInvitationError = "employee_invitation.error.expired"
} else {
employeeInvitationError = "employee_invitation.error.invalid"
}
} else {
employeeInvitation = empInv
}
}
// Generate bot protection challenge token
challenge := botprotection.GenerateChallengeToken()
// Determine error message
var errorMsg string
if rateLimitError == "rate_limited" {
errorMsg = "registration.error.rate_limited"
}
util.RenderHTMLOK(c, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"locations": locations,
"relationshipRoles": relationshipRoles,
"invitation": invitation,
"invitationCode": invitationCode,
"invitationError": invitationError,
"employeeInvitation": employeeInvitation,
"employeeInvitationCode": employeeInvitationCode,
"employeeInvitationError": employeeInvitationError,
"prefilled": invitation != nil,
"isEmployeeRegistration": employeeInvitation != nil,
"challenge": challenge,
"error": errorMsg,
"retryAfter": retryAfter,
})
}
// SubmitRegistration handles the registration form submission
func SubmitRegistration(c *gin.Context) {
lang := c.DefaultQuery("lang", "de")
slog.Debug("registration: form submission started",
"clientIP", c.ClientIP(),
"lang", lang,
)
var form RegistrationForm
if err := c.ShouldBind(&form); err != nil {
slog.Debug("registration: form binding failed",
"error", err.Error(),
"clientIP", c.ClientIP(),
)
regService := service.NewRegistrationService(model.DB)
locations, _ := regService.GetAllLocations()
relationshipRoles := service.GetAllRelationshipRoles()
challenge := botprotection.GenerateChallengeToken()
util.RenderHTML(c, http.StatusBadRequest, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"error": "registration.error.invalid_input",
"form": form,
"locations": locations,
"relationshipRoles": relationshipRoles,
"challenge": challenge,
})
return
}
slog.Debug("registration: form bound successfully",
"email", form.Email,
"firstName", form.FirstName,
"lastName", form.LastName,
"requestType", form.RequestType,
"locationID", form.LocationID,
"groupID", form.GroupID,
"hasInvitationCode", form.InvitationCode != "",
"hasEmployeeInvitationCode", form.EmployeeInvitationCode != "",
"relationshipRole", form.RelationshipRole,
)
// Validate bot protection challenge token
if err := botprotection.ValidateChallengeToken(form.BotCheck, 3*time.Second, 30*time.Minute); err != nil {
slog.Debug("registration: bot protection validation failed",
"error", err.Error(),
"email", form.Email,
)
// Record failure for rate limiting
botprotection.GetRateLimiter().RecordFailure(c.ClientIP(), "challenge_failed")
regService := service.NewRegistrationService(model.DB)
locations, _ := regService.GetAllLocations()
relationshipRoles := service.GetAllRelationshipRoles()
challenge := botprotection.GenerateChallengeToken()
// Determine error message based on failure type
errorKey := "registration.error.invalid_request"
if err == botprotection.ErrMissingChallengeToken {
errorKey = "registration.error.javascript_required"
} else if err == botprotection.ErrFormFilledTooFast {
errorKey = "registration.error.too_fast"
}
util.RenderHTML(c, http.StatusBadRequest, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"error": errorKey,
"form": form,
"locations": locations,
"relationshipRoles": relationshipRoles,
"challenge": challenge,
})
return
}
slog.Debug("registration: bot protection passed",
"email", form.Email,
)
// Check if this is a parent invitation-based registration
var invitation *model.InvitationCode
if form.InvitationCode != "" {
slog.Debug("registration: validating parent invitation code",
"code", form.InvitationCode[:8]+"...",
"email", form.Email,
)
invService := service.NewInvitationService(model.DB)
inv, err := invService.GetInvitationByCode(form.InvitationCode)
if err != nil || !inv.IsValid() {
slog.Debug("registration: parent invitation code invalid",
"error", err,
"email", form.Email,
"isValid", inv != nil && inv.IsValid(),
)
regService := service.NewRegistrationService(model.DB)
locations, _ := regService.GetAllLocations()
relationshipRoles := service.GetAllRelationshipRoles()
challenge := botprotection.GenerateChallengeToken()
errorKey := "invitation.error.invalid"
if err == nil {
if inv.IsUsed() {
errorKey = "invitation.error.already_used"
} else if inv.IsRevoked() {
errorKey = "invitation.error.revoked"
} else if inv.IsExpired() {
errorKey = "invitation.error.expired"
}
}
util.RenderHTML(c, http.StatusBadRequest, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"error": errorKey,
"form": form,
"locations": locations,
"relationshipRoles": relationshipRoles,
"challenge": challenge,
})
return
}
invitation = inv
slog.Debug("registration: parent invitation code valid",
"childID", inv.ChildID,
"childName", inv.Child.FirstName+" "+inv.Child.LastName,
"relationshipRole", inv.RelationshipRole,
"email", form.Email,
)
// Validate and set default RelationshipRole if empty
if invitation.RelationshipRole == "" {
slog.Warn("registration: invitation has empty RelationshipRole, setting default",
"code", form.InvitationCode[:8]+"...",
"childID", invitation.ChildID,
)
invitation.RelationshipRole = model.RelationshipOther
}
}
// Check if this is an employee invitation-based registration
var employeeInvitation *model.EmployeeInvitationCode
if form.EmployeeInvitationCode != "" {
slog.Debug("registration: validating employee invitation code",
"code", form.EmployeeInvitationCode[:8]+"...",
"email", form.Email,
)
empInvService := service.NewEmployeeInvitationService(model.DB)
empInv, err := empInvService.GetEmployeeInvitationByCode(form.EmployeeInvitationCode)
if err != nil || !empInv.IsValid() {
slog.Debug("registration: employee invitation code invalid",
"error", err,
"email", form.Email,
"isValid", empInv != nil && empInv.IsValid(),
)
regService := service.NewRegistrationService(model.DB)
locations, _ := regService.GetAllLocations()
relationshipRoles := service.GetAllRelationshipRoles()
challenge := botprotection.GenerateChallengeToken()
errorKey := "employee_invitation.error.invalid"
if err == nil {
if empInv.IsUsed() {
errorKey = "employee_invitation.error.already_used"
} else if empInv.IsRevoked() {
errorKey = "employee_invitation.error.revoked"
} else if empInv.IsExpired() {
errorKey = "employee_invitation.error.expired"
}
}
util.RenderHTML(c, http.StatusBadRequest, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"error": errorKey,
"form": form,
"locations": locations,
"relationshipRoles": relationshipRoles,
"challenge": challenge,
})
return
}
employeeInvitation = empInv
slog.Debug("registration: employee invitation code valid",
"syncEmployeeID", empInv.SyncEmployeeID,
"employeeName", empInv.SyncEmployee.Vorname+" "+empInv.SyncEmployee.Nachname,
"externalID", empInv.SyncEmployee.ExternalID,
"email", form.Email,
)
}
// Parse child birthday if provided (for non-invitation registrations)
var childBirthday *time.Time
if form.ChildBirthday != "" {
parsed, err := time.Parse("2006-01-02", form.ChildBirthday)
if err == nil {
childBirthday = &parsed
}
}
// Parse group ID
var groupID *uint
if form.GroupID > 0 {
groupID = &form.GroupID
}
// Build registration input
input := service.RegistrationInput{
FirstName: form.FirstName,
LastName: form.LastName,
Email: form.Email,
HoneypotField: form.Relationship, // Honeypot
}
// Use invitation data or form data based on invitation type
if employeeInvitation != nil {
// Employee registration via invitation code
input.RequestType = model.RegistrationTypeEmployee
input.LocationID = form.LocationID // Employee still needs to select location
input.EmployeeInvitationSyncEmployeeID = &employeeInvitation.SyncEmployeeID
input.EmployeeExternalID = &employeeInvitation.SyncEmployee.ExternalID
} else if invitation != nil {
// Parent registration via invitation code
input.RequestType = model.RegistrationTypeParent
input.ChildFirstName = invitation.Child.FirstName
input.ChildLastName = invitation.Child.LastName
input.ChildBirthday = &invitation.Child.Birthday
input.LocationID = invitation.Child.Group.LocationId
input.GroupID = invitation.Child.GroupId
input.RelationshipRole = invitation.RelationshipRole
input.InvitationChildID = &invitation.ChildID
} else {
// Standard registration from form data
input.RequestType = model.RegistrationRequestType(form.RequestType)
input.ChildFirstName = form.ChildFirstName
input.ChildLastName = form.ChildLastName
input.ChildBirthday = childBirthday
input.LocationID = form.LocationID
input.GroupID = groupID
input.RelationshipRole = model.RelationshipRole(form.RelationshipRole)
}
slog.Debug("registration: calling CreateRegistrationRequest",
"email", input.Email,
"requestType", input.RequestType,
"locationID", input.LocationID,
"hasInvitationChildID", input.InvitationChildID != nil,
"hasEmployeeSyncID", input.EmployeeInvitationSyncEmployeeID != nil,
"relationshipRole", input.RelationshipRole,
)
regService := service.NewRegistrationService(model.DB)
request, err := regService.CreateRegistrationRequest(input)
if err != nil {
slog.Debug("registration: CreateRegistrationRequest failed",
"error", err.Error(),
"email", input.Email,
)
locations, _ := regService.GetAllLocations()
relationshipRoles := service.GetAllRelationshipRoles()
challenge := botprotection.GenerateChallengeToken()
// Determine error message based on error type
errorKey := "registration.error.submission_failed"
if err.Error() == "email already registered" {
errorKey = "registration.error.email_exists"
}
util.RenderHTML(c, http.StatusBadRequest, "registration-form.html", gin.H{
"title": "registration.title",
"lang": lang,
"error": errorKey,
"form": form,
"locations": locations,
"relationshipRoles": relationshipRoles,
"challenge": challenge,
})
return
}
slog.Debug("registration: CreateRegistrationRequest succeeded",
"email", input.Email,
"requestID", request.ID,
"status", request.Status,
"createdUserID", request.CreatedUserID,
)
// Mark invitation as used if applicable
if invitation != nil {
slog.Debug("registration: marking parent invitation as used",
"code", form.InvitationCode[:8]+"...",
"email", form.Email,
)
invService := service.NewInvitationService(model.DB)
if err := invService.UseInvitationCode(form.InvitationCode, form.Email); err != nil {
slog.Debug("registration: failed to mark parent invitation as used",
"error", err.Error(),
"email", form.Email,
)
}
}
// Mark employee invitation as used if applicable
if employeeInvitation != nil {
slog.Debug("registration: marking employee invitation as used",
"code", form.EmployeeInvitationCode[:8]+"...",
"email", form.Email,
)
empInvService := service.NewEmployeeInvitationService(model.DB)
if err := empInvService.UseEmployeeInvitationCode(form.EmployeeInvitationCode, form.Email); err != nil {
slog.Debug("registration: failed to mark employee invitation as used",
"error", err.Error(),
"email", form.Email,
)
}
}
slog.Debug("registration: completed successfully, redirecting to success page",
"email", form.Email,
)
// Redirect to success page (with auto=1 for QR registrations)
if invitation != nil || employeeInvitation != nil {
c.Redirect(http.StatusSeeOther, "/register/success?lang="+lang+"&auto=1")
} else {
c.Redirect(http.StatusSeeOther, "/register/success?lang="+lang)
}
}
// RegistrationSuccess displays the registration success page
func RegistrationSuccess(c *gin.Context) {
lang := c.DefaultQuery("lang", "de")
autoApproved := c.Query("auto") == "1"
util.RenderHTMLOK(c, "registration-success.html", gin.H{
"title": "registration.success.title",
"lang": lang,
"autoApproved": autoApproved,
})
}
// PendingApprovalPage displays the pending approval page for unapproved users
func PendingApprovalPage(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
user := getUserFromContext(c)
if user == nil {
c.Redirect(http.StatusSeeOther, "/login")
return
}
// If user is already approved, redirect to home
if user.IsApproved() {
c.Redirect(http.StatusSeeOther, "/"+langStr)
return
}
util.RenderHTMLOK(c, "pending-approval.html", gin.H{
"title": "pending.title",
"lang": langStr,
"user": user,
})
}
// ShowActivationForm displays the account activation form
func ShowActivationForm(c *gin.Context) {
code := c.Param("code")
lang := c.DefaultQuery("lang", "de")
regService := service.NewRegistrationService(model.DB)
user, err := regService.GetUserByActivationCode(code)
if err != nil || user == nil {
util.RenderHTML(c, http.StatusNotFound, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"error": "activation.error.invalid_code",
})
return
}
if user.Activated {
util.RenderHTML(c, http.StatusBadRequest, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"error": "activation.error.already_activated",
})
return
}
util.RenderHTMLOK(c, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"code": code,
"firstName": user.FirstName,
})
}
// ActivateAccount handles the account activation form submission
func ActivateAccount(c *gin.Context) {
code := c.Param("code")
lang := c.DefaultQuery("lang", "de")
password := c.PostForm("password")
passwordConfirm := c.PostForm("password_confirm")
if password == "" || len(password) < 10 {
util.RenderHTML(c, http.StatusBadRequest, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"code": code,
"error": "activation.error.password_too_short",
})
return
}
if password != passwordConfirm {
util.RenderHTML(c, http.StatusBadRequest, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"code": code,
"error": "activation.error.passwords_mismatch",
})
return
}
regService := service.NewRegistrationService(model.DB)
err := regService.ActivateUser(code, password)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "activation-form.html", gin.H{
"title": "activation.title",
"lang": lang,
"code": code,
"error": "activation.error.activation_failed",
})
return
}
// Redirect to login
c.Redirect(http.StatusSeeOther, "/login?activated=true")
}
// GetGroupsByLocation returns groups for a location as JSON (for AJAX)
func GetGroupsByLocation(c *gin.Context) {
locationIDStr := c.Param("id")
locationID, err := strconv.ParseUint(locationIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid location ID"})
return
}
regService := service.NewRegistrationService(model.DB)
groups, err := regService.GetGroupsByLocation(uint(locationID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load groups"})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
// === Admin Registration Management ===
// AdminRegistrationsList shows pending registration requests
func AdminRegistrationsList(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
user := getUserFromContext(c)
if user == nil || (!user.IsAdmin() && !user.IsHouseLeader() && !user.IsGroupLeader()) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
})
return
}
regService := service.NewRegistrationService(model.DB)
requests, err := regService.GetPendingRequestsForUser(user)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
"title": "Error",
"lang": langStr,
"error": "Failed to load registrations",
"user": user,
})
return
}
util.RenderHTMLOK(c, "admin-registrations-list.html", gin.H{
"title": "admin.registrations.title",
"lang": langStr,
"user": user,
"requests": requests,
})
}
// AdminRegistrationDetail shows a single registration request detail
func AdminRegistrationDetail(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
user := getUserFromContext(c)
if user == nil {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
util.RenderHTML(c, http.StatusBadRequest, "error.html", gin.H{
"title": "Error",
"lang": langStr,
"error": "Invalid registration ID",
"user": user,
})
return
}
regService := service.NewRegistrationService(model.DB)
request, err := regService.GetRegistrationByID(uint(id))
if err != nil {
util.RenderHTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "Not Found",
"lang": langStr,
"user": user,
})
return
}
// Check access
if !regService.CanUserAccessRegistration(user, request) {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{
"title": "Forbidden",
"lang": langStr,
"user": user,
})
return
}
// For approved/auto-approved requests, get the activation code if visible
var activationCode string
if request.CreatedUserID != nil && (request.IsApproved() || request.IsAutoApproved()) {
var createdUser model.User
if err := model.DB.First(&createdUser, *request.CreatedUserID).Error; err == nil {
if createdUser.ActivateCode != nil && !createdUser.Activated {
activationCode = *createdUser.ActivateCode
}
}
}
util.RenderHTMLOK(c, "admin-registrations-detail.html", gin.H{
"title": "admin.registrations.detail.title",
"lang": langStr,
"user": user,
"request": request,
"activationCode": activationCode,
"canApprove": request.IsPending() && (user.IsAdmin() || request.IsParentRegistration()),
})
}
// AdminApproveRegistration approves a registration request
func AdminApproveRegistration(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
user := getUserFromContext(c)
if user == nil {
c.Redirect(http.StatusSeeOther, "/login")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
regService := service.NewRegistrationService(model.DB)
request, err := regService.GetRegistrationByID(uint(id))
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
// Check access
if !regService.CanUserAccessRegistration(user, request) {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
// Employee approval requires admin
if request.IsEmployeeRegistration() && !user.IsAdmin() {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations/"+idStr+"?error=admin_required")
return
}
err = regService.ApproveRegistration(uint(id), user.ID)
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations/"+idStr+"?error=approval_failed")
return
}
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations/"+idStr+"?success=approved")
}
// AdminRejectRegistration rejects a registration request
func AdminRejectRegistration(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
user := getUserFromContext(c)
if user == nil {
c.Redirect(http.StatusSeeOther, "/login")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
regService := service.NewRegistrationService(model.DB)
request, err := regService.GetRegistrationByID(uint(id))
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
// Check access
if !regService.CanUserAccessRegistration(user, request) {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations")
return
}
reason := c.PostForm("reason")
err = regService.RejectRegistration(uint(id), user.ID, reason)
if err != nil {
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations/"+idStr+"?error=rejection_failed")
return
}
c.Redirect(http.StatusSeeOther, "/"+langStr+"/admin/registrations/"+idStr+"?success=rejected")
}
// getUserFromContext retrieves the authenticated user from context
func getUserFromContext(c *gin.Context) *model.User {
userIdent, exists := c.Get("userident")
if !exists {
return nil
}
var user model.User
if err := model.DB.Preload("Roles").First(&user, userIdent).Error; err != nil {
return nil
}
return &user
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ChangeRole handles role switching for dual-role users (e.g., Employee+Parent)
// The active role is stored in a cookie and affects which view the user sees
func ChangeRole(c *gin.Context) {
// Get authenticated user
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusSeeOther, "/login")
return
}
user := userInterface.(*model.User)
// Get requested role from form
requestedRole := c.PostForm("role")
// Validate user has this role
if !user.HasRole(requestedRole) {
// User doesn't have this role, redirect back without changing
referer := c.GetHeader("Referer")
if referer == "" {
referer = "/"
}
c.Redirect(http.StatusSeeOther, referer)
return
}
// Set cookie (30 days expiration)
// Path "/" ensures cookie is available on all routes
c.SetCookie("active_role", requestedRole, 60*60*24*30, "/", "", util.SecureCookies(), true)
// Redirect back to the same page
referer := c.GetHeader("Referer")
if referer == "" {
referer = "/"
}
c.Redirect(http.StatusSeeOther, referer)
}
package controller
// User settings controller - handles user settings and password change
//
// [impl->dsn~benutzereinstellungen~1]
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// ShowUserSettings displays the user settings overview page
func ShowUserSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Get pending email as string for template
var pendingEmail string
if user.PendingEmail != nil {
pendingEmail = *user.PendingEmail
}
data := gin.H{
"title": "settings.title",
"lang": langStr,
"user": user,
"pendingEmail": pendingEmail,
"notifyByEmail": user.NotifyByEmail,
}
// Stand-in (Stellvertretung) section — only for GroupLead and LocationLead.
if user.IsGroupLeader() || user.IsHouseLeader() {
standIns, candidates := LoadStandInDataForLead(user)
data["standIns"] = standIns
data["standInCandidates"] = candidates
}
// Check for success / error / info messages from redirect
if success := c.Query("success"); success != "" {
data["success"] = success
}
if errKey := c.Query("error"); errKey != "" {
data["error"] = errKey
}
if info := c.Query("info"); info != "" {
data["info"] = info
}
util.RenderHTMLOK(c, "settings.html", data)
}
// ShowPasswordChangeForm displays the password change form
func ShowPasswordChangeForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Check if this is a forced password change
isForced := user.ForcePasswordChange
util.RenderHTMLOK(c, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"isForced": isForced,
})
}
// ChangePassword handles the password change form submission
func ChangePassword(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
currentPassword := c.PostForm("current_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Get current password hash
var passwd model.Passwd
if err := model.DB.Where("user_id = ?", user.ID).First(&passwd).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.general",
})
return
}
// Verify current password
if !util.CompareHashPassword(currentPassword, passwd.PassHash) {
util.RenderHTML(c, http.StatusBadRequest, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.current_wrong",
})
return
}
// Validate new password length (minimum 10 characters)
if len(newPassword) < 10 {
util.RenderHTML(c, http.StatusBadRequest, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.too_short",
})
return
}
// Validate password confirmation
if newPassword != confirmPassword {
util.RenderHTML(c, http.StatusBadRequest, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.mismatch",
})
return
}
// Hash new password
newHash, err := util.GenerateHashPassword(newPassword)
if err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.general",
})
return
}
// Update password in database
passwd.PassHash = newHash
if err := model.DB.Save(&passwd).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.general",
})
return
}
// Check if this was a forced password change
wasForced := user.ForcePasswordChange
// Clear the force password change flag if set
if user.ForcePasswordChange {
user.ForcePasswordChange = false
if err := model.DB.Save(user).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"error": "settings.password.error.general",
})
return
}
}
// If was forced, redirect to home
if wasForced {
c.Redirect(http.StatusFound, "/"+langStr+"/")
return
}
// Show success message
util.RenderHTMLOK(c, "settings-password.html", gin.H{
"title": "settings.password.title",
"lang": langStr,
"user": user,
"success": "settings.password.success",
})
}
// ShowEmailChangeForm displays the email change form
func ShowEmailChangeForm(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
util.RenderHTMLOK(c, "settings-email.html", gin.H{
"title": "settings.email.title",
"lang": langStr,
"user": user,
})
}
// RequestEmailChange handles the email change form submission
func RequestEmailChange(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
newEmail := strings.TrimSpace(strings.ToLower(c.PostForm("new_email")))
currentPassword := c.PostForm("current_password")
renderError := func(errorKey string) {
util.RenderHTML(c, http.StatusBadRequest, "settings-email.html", gin.H{
"title": "settings.email.title",
"lang": langStr,
"user": user,
"error": errorKey,
"newEmail": newEmail,
})
}
// Validate new email is not empty
if newEmail == "" {
renderError("settings.email.error.invalid")
return
}
// Validate email is different from current
if newEmail == user.Email {
renderError("settings.email.error.same")
return
}
// Verify current password
var passwd model.Passwd
if err := model.DB.Where("user_id = ?", user.ID).First(&passwd).Error; err != nil {
renderError("settings.email.error.general")
return
}
if !util.CompareHashPassword(currentPassword, passwd.PassHash) {
renderError("settings.email.error.password")
return
}
// Check uniqueness of new email
var existingUser model.User
if model.DB.Where("email = ? AND id != ?", newEmail, user.ID).First(&existingUser).Error == nil {
renderError("settings.email.error.taken")
return
}
// Generate verification code (32-char hex)
codeBytes := make([]byte, 16)
if _, err := rand.Read(codeBytes); err != nil {
renderError("settings.email.error.general")
return
}
code := hex.EncodeToString(codeBytes)
// Save pending email and code
user.PendingEmail = &newEmail
user.PendingEmailCode = &code
if err := model.DB.Save(user).Error; err != nil {
renderError("settings.email.error.general")
return
}
// Send verification email to the new address
firstName := user.FirstName
if firstName == "" {
firstName = user.Email
}
go service.SendEmailChangeVerification(newEmail, firstName, newEmail, code, langStr)
// Redirect to settings with success message
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.email.success")
}
// ConfirmEmailChange handles the email change confirmation link (public route)
func ConfirmEmailChange(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
code := c.Param("code")
if code == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/login?error=settings.email.error.invalid_code")
return
}
// Find user by pending email code
var user model.User
if err := model.DB.Where("pending_email_code = ?", code).First(&user).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/login?error=settings.email.error.invalid_code")
return
}
if user.PendingEmail == nil {
c.Redirect(http.StatusFound, "/"+langStr+"/login?error=settings.email.error.invalid_code")
return
}
newEmail := *user.PendingEmail
// Re-check uniqueness (another user could have taken this email in the meantime)
var existingUser model.User
if model.DB.Where("email = ? AND id != ?", newEmail, user.ID).First(&existingUser).Error == nil {
c.Redirect(http.StatusFound, "/"+langStr+"/login?error=settings.email.error.already_taken")
return
}
// Swap email and clear pending fields
user.Email = newEmail
user.PendingEmail = nil
user.PendingEmailCode = nil
if err := model.DB.Save(&user).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/login?error=settings.email.error.general")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/login?success=settings.email.confirmed")
}
// CancelEmailChange handles cancelling a pending email change
func CancelEmailChange(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Clear pending email fields
user.PendingEmail = nil
user.PendingEmailCode = nil
model.DB.Save(user)
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.email.cancelled")
}
// UpdateContactDetails handles updating user address and phone number
func UpdateContactDetails(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
user.Address = strings.TrimSpace(c.PostForm("address"))
user.PhoneNumber = strings.TrimSpace(c.PostForm("phone_number"))
if err := model.DB.Save(user).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.contact.error")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.contact.saved")
}
// UpdateNotificationSettings handles toggling email notification preferences
func UpdateNotificationSettings(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Checkbox: present in form data means checked, absent means unchecked
user.NotifyByEmail = c.PostForm("notify_by_email") == "on"
if err := model.DB.Save(user).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.notifications.error")
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=settings.notifications.saved")
}
package controller
import (
"net/http"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// SettingsRefreshPermissions shows the refresh permissions page for employees
func SettingsRefreshPermissions(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only employees with ExternalID can use this page
if !user.IsEmployee() || user.ExternalID == nil || *user.ExternalID == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/settings")
return
}
// Get daily groups for display
dailyGroups, _ := service.GetUserDailyGroups(model.DB, user.ID)
util.RenderHTMLOK(c, "settings-refresh.html", gin.H{
"lang": langStr,
"user": user,
"dailyGroups": dailyGroups,
"refreshFailed": user.IntranetRefreshFailed,
"title": "Refresh Permissions",
"success": c.Query("success"),
"error": c.Query("error"),
})
}
// SettingsRefreshPermissionsPost handles manual refresh request
func SettingsRefreshPermissionsPost(c *gin.Context) {
lang, _ := c.Get("language")
langStr, ok := lang.(string)
if !ok || langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
// Only employees with ExternalID can refresh
if !user.IsEmployee() || user.ExternalID == nil || *user.ExternalID == "" {
c.Redirect(http.StatusFound, "/"+langStr+"/settings")
return
}
// Attempt to refresh
err := service.RefreshUserGroupMemberships(model.DB, user)
if err != nil {
logger.Warn("Manual intranet refresh failed",
"userId", user.ID,
"email", user.Email,
"error", err)
c.Redirect(http.StatusFound, "/"+langStr+"/settings/refresh-permissions?error=refresh_failed")
return
}
logger.Info("Manual intranet refresh successful",
"userId", user.ID,
"email", user.Email)
c.Redirect(http.StatusFound, "/"+langStr+"/settings/refresh-permissions?success=refreshed")
}
package controller
// Stand-in (Stellvertretung) controllers — let a GroupLead or LocationLead
// authorise an Employee to write parental letters and news on their behalf.
//
// [impl->dsn~stellvertretung-design~1]
import (
"net/http"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// CandidateDelegate is a lightweight view of an Employee who could receive a stand-in.
type CandidateDelegate struct {
ID uint
DisplayName string
Email string
}
// LoadStandInDataForLead returns the data the settings template needs to
// render the Stellvertretung section for a GroupLead or LocationLead.
// - existing: the lead's currently-active stand-ins (with delegate preloaded)
// - candidates: employees in the lead's scope who could receive a new stand-in,
// excluding the lead themselves
func LoadStandInDataForLead(user *model.User) (existing []model.LeadStandIn, candidates []CandidateDelegate) {
if user == nil || (!user.IsGroupLeader() && !user.IsHouseLeader()) {
return nil, nil
}
existing, _ = model.ActiveStandInsForDelegator(model.DB, user.ID)
// Candidate delegates: Employees assigned to any group the lead leads or oversees.
var groupIDs []uint
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(model.DB)
if err == nil && len(locationIDs) > 0 {
model.DB.Model(&model.Group{}).
Where("location_id IN ?", locationIDs).
Pluck("id", &groupIDs)
}
} else if user.IsGroupLeader() {
// GroupLead's own group(s).
model.DB.Model(&model.Group{}).
Where("lead_id = ?", user.ID).
Pluck("id", &groupIDs)
}
if len(groupIDs) == 0 {
return existing, nil
}
// Exclude employees who already hold an active stand-in from this lead,
// so the lead can't accidentally grant two parallel stand-ins to the same
// person (revoking one would leave the other live).
now := time.Now()
alreadyDelegated := model.DB.Model(&model.LeadStandIn{}).
Select("delegate_id").
Where("delegator_id = ? AND revoked_at IS NULL AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)",
user.ID, now, now)
var users []model.User
model.DB.Distinct().
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Employee").
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Where("group_teachers.group_id IN ?", groupIDs).
Where("users.id != ?", user.ID).
Where("users.activated = ? AND users.deactivated = ?", true, false).
Where("users.id NOT IN (?)", alreadyDelegated).
Find(&users)
for i := range users {
candidates = append(candidates, CandidateDelegate{
ID: users[i].ID,
DisplayName: users[i].DisplayName(),
Email: users[i].Email,
})
}
return existing, candidates
}
// AdminListStandIns shows every currently-active stand-in across the org.
// Read-only; useful for spotting forgotten grants.
func AdminListStandIns(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
now := time.Now()
var standIns []model.LeadStandIn
if err := model.DB.
Preload("Delegator").Preload("Delegator.Roles").
Preload("Delegate").
Where("revoked_at IS NULL AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)", now, now).
Order("valid_from DESC").
Find(&standIns).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user, "title": "Error"})
return
}
util.RenderHTMLOK(c, "admin-stand-ins.html", gin.H{
"lang": langStr,
"user": user,
"standIns": standIns,
"title": "Stand-ins",
})
}
// CreateStandIn handles the POST that creates a new stand-in record.
// Form fields: delegate_id (uint, required), valid_until (YYYY-MM-DD, optional), note (string, optional)
func CreateStandIn(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
if !user.IsGroupLeader() && !user.IsHouseLeader() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
delegateIDStr := c.PostForm("delegate_id")
delegateID64, err := strconv.ParseUint(delegateIDStr, 10, 32)
if err != nil || delegateID64 == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.invalid_delegate")
return
}
delegateID := uint(delegateID64)
if delegateID == user.ID {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.self_not_allowed")
return
}
// Confirm the candidate is valid (Employee in the lead's scope).
_, candidates := LoadStandInDataForLead(user)
candidateOK := false
for _, cand := range candidates {
if cand.ID == delegateID {
candidateOK = true
break
}
}
if !candidateOK {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.invalid_delegate")
return
}
var validUntil *time.Time
if raw := strings.TrimSpace(c.PostForm("valid_until")); raw != "" {
parsed, err := time.Parse("2006-01-02", raw)
if err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.invalid_date")
return
}
// Treat the whole day as valid: end-of-day.
eod := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, parsed.Location())
if eod.Before(time.Now()) {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.date_in_past")
return
}
validUntil = &eod
}
note := strings.TrimSpace(c.PostForm("note"))
if len(note) > 500 {
note = note[:500]
}
standIn := model.LeadStandIn{
DelegatorID: user.ID,
DelegateID: delegateID,
ValidFrom: time.Now(),
ValidUntil: validUntil,
Note: note,
}
if err := model.DB.Create(&standIn).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user, "title": "Error"})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=stand_in.created")
}
// RevokeStandIn handles the POST that revokes an existing stand-in.
func RevokeStandIn(c *gin.Context) {
lang, _ := c.Get("language")
langStr, _ := lang.(string)
if langStr == "" {
langStr = "de"
}
userInterface, ok := c.Get("User")
if !ok {
c.Redirect(http.StatusFound, "/"+langStr+"/login")
return
}
user := userInterface.(*model.User)
idStr := c.Param("id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil || id64 == 0 {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.invalid_id")
return
}
var standIn model.LeadStandIn
if err := model.DB.First(&standIn, uint(id64)).Error; err != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?error=stand_in.not_found")
return
}
// Only the delegator (or an admin) can revoke.
if standIn.DelegatorID != user.ID && !user.IsAdmin() {
util.RenderHTML(c, http.StatusForbidden, "403.html", gin.H{"lang": langStr, "user": user})
return
}
if standIn.RevokedAt != nil {
c.Redirect(http.StatusFound, "/"+langStr+"/settings?info=stand_in.already_revoked")
return
}
now := time.Now()
standIn.RevokedAt = &now
if err := model.DB.Save(&standIn).Error; err != nil {
util.RenderHTML(c, http.StatusInternalServerError, "500.html", gin.H{"lang": langStr, "user": user, "title": "Error"})
return
}
c.Redirect(http.StatusFound, "/"+langStr+"/settings?success=stand_in.revoked")
}
package controller
import (
"testing"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/testhelpers"
"wippidu_app_backend/internal/util"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// SetupTestDBWithModels creates a test database with all models migrated.
// The model list is sourced from model.AllModels() so adding a new
// persisted entity in one place (model/db.go) is enough — no test setup
// here, in internal/model/test_helpers.go, or in the dbexport / dbimport
// test files needs to be touched.
func SetupTestDBWithModels(t *testing.T) *gorm.DB {
db := testhelpers.SetupTestDB(t)
err := db.AutoMigrate(model.AllModels()...)
require.NoError(t, err)
// Set global DB
model.DB = db
// Populate basic roles
model.PopulateBasicData()
return db
}
// CreateTestUser creates a test user with the specified role
func CreateTestUser(t *testing.T, db *gorm.DB, email string, roleName string) *model.User {
user := &model.User{
Email: email,
Address: "Test Address 123",
Birthday: time.Now().AddDate(-30, 0, 0),
ActivatedAt: time.Now(),
ValidUntil: time.Now().AddDate(1, 0, 0),
Activated: true,
}
err := db.Create(user).Error
require.NoError(t, err)
// Assign role if specified
if roleName != "" {
// Enforce role hierarchy: GroupLead and LocationLead must also have Employee role
if roleName == "GroupLead" || roleName == "LocationLead" {
employeeRole := &model.Role{}
err = db.Where("name = ?", "Employee").First(employeeRole).Error
require.NoError(t, err)
err = db.Model(user).Association("Roles").Append(employeeRole)
require.NoError(t, err)
}
// Assign the specified role
role := &model.Role{}
err = db.Where("name = ?", roleName).First(role).Error
require.NoError(t, err)
err = db.Model(user).Association("Roles").Append(role)
require.NoError(t, err)
// Reload user with roles
db.Preload("Roles").First(user, user.ID)
}
return user
}
// CreateTestUserWithPassword creates a test user with a hashed password
func CreateTestUserWithPassword(t *testing.T, db *gorm.DB, email string, password string, roleName string) *model.User {
user := CreateTestUser(t, db, email, roleName)
// Create password hash
hash, err := util.GenerateHashPassword(password)
require.NoError(t, err)
passwd := &model.Passwd{
UserId: user.ID,
PassHash: hash,
}
err = db.Create(passwd).Error
require.NoError(t, err)
return user
}
// CreateTestLocation creates a test location
func CreateTestLocation(t *testing.T, db *gorm.DB, name string) *model.Location {
location := &model.Location{
Name: name,
Address: "123 Test Street",
}
err := db.Create(location).Error
require.NoError(t, err)
return location
}
// CreateTestGroup creates a test group
func CreateTestGroup(t *testing.T, db *gorm.DB, name string, locationID uint) *model.Group {
group := &model.Group{
Name: name,
LocationId: locationID,
}
err := db.Create(group).Error
require.NoError(t, err)
// Reload with location
db.Preload("Location").First(group, group.ID)
return group
}
// CreateTestChild creates a test child
func CreateTestChild(t *testing.T, db *gorm.DB, firstName string, lastName string, groupID uint) *model.Child {
child := &model.Child{
FirstName: firstName,
LastName: lastName,
Birthday: time.Now().AddDate(-5, 0, 0),
Active: true,
GroupId: &groupID,
}
err := db.Create(child).Error
require.NoError(t, err)
// Reload with associations
db.Preload("Group.Location").First(child, child.ID)
return child
}
package email
import (
"bytes"
"embed"
htmltemplate "html/template"
texttemplate "text/template"
)
//go:embed templates/*.html templates/*.txt
var templateFS embed.FS
// TemplateData holds data for rendering email templates
type TemplateData struct {
AppName string
FirstName string
ActivationURL string
ActivationCode string
Reason string
Lang string
VerificationURL string
NewEmail string
ItemType string // "news", "letter", "message"
ItemTitle string
ItemPreview string
ItemURL string
LetterSubject string
ReviewURL string
}
// EmailContent holds rendered email content
type EmailContent struct {
Subject string
HTMLBody string
PlainBody string
}
// RenderActivationEmail renders the activation email for a given language
func RenderActivationEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("activation", data)
}
// RenderRejectionEmail renders the rejection email for a given language
func RenderRejectionEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("rejection", data)
}
// RenderTestEmail renders a simple test email
func RenderTestEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("test", data)
}
// RenderEmailChangeEmail renders the email change verification email
func RenderEmailChangeEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("email-change", data)
}
// RenderNotificationEmail renders the notification email for published items
func RenderNotificationEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("notification", data)
}
// RenderReviewRequestEmail renders the review request email for parental letters
func RenderReviewRequestEmail(data TemplateData) (*EmailContent, error) {
return renderEmail("review-request", data)
}
func renderEmail(templateName string, data TemplateData) (*EmailContent, error) {
// Render HTML body
htmlTmpl, err := htmltemplate.ParseFS(templateFS, "templates/"+templateName+".html")
if err != nil {
return nil, err
}
var htmlBuf bytes.Buffer
if err := htmlTmpl.Execute(&htmlBuf, data); err != nil {
return nil, err
}
// Render plain text body
textTmpl, err := texttemplate.ParseFS(templateFS, "templates/"+templateName+".txt")
if err != nil {
return nil, err
}
var textBuf bytes.Buffer
if err := textTmpl.Execute(&textBuf, data); err != nil {
return nil, err
}
// Generate subject based on template type and language
subject := getSubject(templateName, data.Lang)
return &EmailContent{
Subject: subject,
HTMLBody: htmlBuf.String(),
PlainBody: textBuf.String(),
}, nil
}
func getSubject(templateName, lang string) string {
subjects := map[string]map[string]string{
"activation": {
"de": "Wippidu - Konto aktivieren",
"en": "Wippidu - Activate Your Account",
},
"rejection": {
"de": "Wippidu - Ihre Registrierung",
"en": "Wippidu - Your Registration",
},
"test": {
"de": "Wippidu - Test E-Mail",
"en": "Wippidu - Test Email",
},
"email-change": {
"de": "Wippidu - E-Mail-Adresse ändern",
"en": "Wippidu - Change Email Address",
},
"notification": {
"de": "Wippidu - Neuer Beitrag veröffentlicht",
"en": "Wippidu - New Item Published",
},
"review-request": {
"de": "Wippidu - Elternbrief zur Prüfung",
"en": "Wippidu - Parental Letter Review Request",
},
}
if langSubjects, ok := subjects[templateName]; ok {
if subject, ok := langSubjects[lang]; ok {
return subject
}
return langSubjects["en"] // fallback to English
}
return "Wippidu"
}
package i18n
import (
"embed"
"encoding/json"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed locales/*.json
var localeFS embed.FS
var bundle *i18n.Bundle
// Init initializes the i18n bundle with translation files
func Init() error {
bundle = i18n.NewBundle(language.German)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Load German translations
if _, err := bundle.LoadMessageFileFS(localeFS, "locales/de.json"); err != nil {
return err
}
// Load English translations
if _, err := bundle.LoadMessageFileFS(localeFS, "locales/en.json"); err != nil {
return err
}
return nil
}
// GetLocalizer returns a localizer for the given language
func GetLocalizer(lang string) *i18n.Localizer {
return i18n.NewLocalizer(bundle, lang)
}
// Translate translates a message key to the specified language
func Translate(lang, key string) string {
localizer := GetLocalizer(lang)
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
})
if err != nil {
// Return the key itself if translation not found
return key
}
return msg
}
// TranslateWithData translates a message key with template data
func TranslateWithData(lang, key string, templateData map[string]interface{}) string {
localizer := GetLocalizer(lang)
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateData,
})
if err != nil {
return key
}
return msg
}
// TranslatePlural translates a message key with plural support based on count
func TranslatePlural(lang, key string, count int) string {
localizer := GetLocalizer(lang)
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
PluralCount: count,
})
if err != nil {
return key
}
return msg
}
// SupportedLanguages returns the list of supported language codes
func SupportedLanguages() []string {
return []string{"de", "en"}
}
// IsSupported checks if a language code is supported
func IsSupported(lang string) bool {
for _, supported := range SupportedLanguages() {
if lang == supported {
return true
}
}
return false
}
// DefaultLanguage returns the default language code
func DefaultLanguage() string {
return "de"
}
package integrationtesting
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/google/uuid"
)
// uintPtr returns a pointer to the given uint value
func uintPtr(v uint) *uint {
return &v
}
// Contract validity status types for test data (children)
type slowContractStatus int
const (
slowContractActive slowContractStatus = iota // Valid for 1+ year
slowContractExpiringSoon // Expires within 30 days
slowContractExpired // Already expired
slowContractNewlyStarted // Started recently
slowContractUnlimited // No dates set (NULL)
)
// Access validity status for UserChild relationships
type slowAccessStatus int
const (
slowAccessUnlimited slowAccessStatus = iota // No dates set (NULL) - most common
slowAccessActive // Has specific dates, currently valid
slowAccessExpiringSoon // Access expires within 30 days
slowAccessExpired // Access has expired
slowAccessFuture // Access starts in the future
)
// childContractStatus maps child names to their contract status for testing
var childContractStatus = map[string]slowContractStatus{
// Expiring soon
"Sophia Weber": slowContractExpiringSoon,
"Mason Schmidt": slowContractExpiringSoon,
"Elizabeth Lehmann": slowContractExpiringSoon,
"Scarlett Berger": slowContractExpiringSoon,
"Wyatt Sauer": slowContractExpiringSoon,
// Expired
"Elijah Becker": slowContractExpired,
"Alexander Werner": slowContractExpired,
"Jack Becker": slowContractExpired,
// Newly started
"Lucas Hoffmann": slowContractNewlyStarted,
"Harper Braun": slowContractNewlyStarted,
"Avery Fuchs": slowContractNewlyStarted,
"Grace Winter": slowContractNewlyStarted,
"Gabriel Jung": slowContractNewlyStarted,
// Unlimited
"Oliver Richter": slowContractUnlimited,
"Henry Lange": slowContractUnlimited,
"Owen Sommer": slowContractUnlimited,
"Carter Vogel": slowContractUnlimited,
// All others default to slowContractActive
}
// parentAccessStatus maps (email, childName) to access status for testing
var parentAccessStatus = map[string]slowAccessStatus{
// Expiring soon
"liam.schmidt@wippidu.app:Mason Schmidt": slowAccessExpiringSoon,
"elizabeth.lehmann@wippidu.app:Elizabeth Lehmann": slowAccessExpiringSoon,
"scarlett.berger@wippidu.app:Scarlett Berger": slowAccessExpiringSoon,
"wyatt.sauer@wippidu.app:Wyatt Sauer": slowAccessExpiringSoon,
"thomas.weber@wippidu.app:Sophia Weber": slowAccessExpiringSoon,
// Expired
"elijah.becker@wippidu.app:Jack Becker": slowAccessExpired,
"elijah.becker.alpha@wippidu.app:Elijah Becker": slowAccessExpired,
"alexander.werner@wippidu.app:Alexander Werner": slowAccessExpired,
"julia.becker@wippidu.app:Jack Becker": slowAccessExpired,
// Active with specific dates
"harper.braun@wippidu.app:Harper Braun": slowAccessActive,
"lucas.hoffmann@wippidu.app:Lucas Hoffmann": slowAccessActive,
"avery.fuchs@wippidu.app:Avery Fuchs": slowAccessActive,
"grace.winter@wippidu.app:Grace Winter": slowAccessActive,
"gabriel.jung@wippidu.app:Gabriel Jung": slowAccessActive,
"stefan.koch@wippidu.app:Mia Koch": slowAccessActive,
// Future access
"jan.otto@wippidu.app:Zoey Otto": slowAccessFuture,
// All others default to slowAccessUnlimited
}
// getChildValidityDates returns ValidFrom and ValidUntil based on contract status
func getChildValidityDates(childName string) (*time.Time, *time.Time, bool) {
now := time.Now()
status, exists := childContractStatus[childName]
if !exists {
status = slowContractActive
}
switch status {
case slowContractActive:
validFrom := now.AddDate(-1, -6, 0)
validUntil := now.AddDate(1, 0, 0)
return &validFrom, &validUntil, true
case slowContractExpiringSoon:
validFrom := now.AddDate(-1, 0, 0)
validUntil := now.AddDate(0, 0, 20)
return &validFrom, &validUntil, true
case slowContractExpired:
validFrom := now.AddDate(-2, 0, 0)
validUntil := now.AddDate(0, 0, -30)
return &validFrom, &validUntil, false // inactive
case slowContractNewlyStarted:
validFrom := now.AddDate(0, -2, 0)
validUntil := now.AddDate(2, 0, 0)
return &validFrom, &validUntil, true
case slowContractUnlimited:
return nil, nil, true
}
return nil, nil, true
}
// getUserChildValidityDates returns ValidFrom and ValidUntil based on access status
func getUserChildValidityDates(email, childName string) (*time.Time, *time.Time) {
now := time.Now()
key := email + ":" + childName
status, exists := parentAccessStatus[key]
if !exists {
return nil, nil // unlimited access
}
switch status {
case slowAccessUnlimited:
return nil, nil
case slowAccessActive:
validFrom := now.AddDate(-1, 0, 0)
validUntil := now.AddDate(1, 0, 0)
return &validFrom, &validUntil
case slowAccessExpiringSoon:
validFrom := now.AddDate(-1, 0, 0)
validUntil := now.AddDate(0, 0, 20)
return &validFrom, &validUntil
case slowAccessExpired:
validFrom := now.AddDate(-2, 0, 0)
validUntil := now.AddDate(0, 0, -30)
return &validFrom, &validUntil
case slowAccessFuture:
validFrom := now.AddDate(0, 0, 14)
validUntil := now.AddDate(1, 0, 14)
return &validFrom, &validUntil
}
return nil, nil
}
// to be extended with more when needed...
func CreateUserWithLogin(email, password, firstName, lastName string) error {
approvedAt := time.Now()
var dummy model.User = model.User{
Email: email,
FirstName: firstName,
LastName: lastName,
Address: "",
Birthday: time.Now(),
ActivatedAt: time.Now(),
Activated: true,
Approved: true,
ApprovedAt: &approvedAt,
}
model.DB.Where("email = ?", dummy.Email).First(&dummy)
if dummy.ID == 0 {
model.DB.Create(&dummy)
model.DB.Where("email = ?", dummy.Email).First(&dummy)
pwhash, err := util.GenerateHashPassword(password)
util.LogFatal(err, "password generation on testdata failed")
model.DB.Create(&model.Passwd{
UserId: dummy.ID,
PassHash: pwhash,
})
fmt.Printf("Created User %s (%s %s)\n", email, firstName, lastName)
} else {
// Update first/last name if user exists but names are empty
if dummy.FirstName == "" && firstName != "" {
dummy.FirstName = firstName
dummy.LastName = lastName
model.DB.Save(&dummy)
fmt.Printf("Updated User %s with name: %s %s\n", email, firstName, lastName)
} else {
fmt.Printf("User %s exists.\n", email)
}
}
if dummy.ID == 0 {
return fmt.Errorf("Fundamentally broken")
}
return nil
}
func CreateLocationsAndGroups() {
// Location Alpha with 3 groups
locationAlpha := model.Location{}
model.DB.Where("name = ?", "Alpha").First(&locationAlpha)
if locationAlpha.ID == 0 {
locationAlpha.Name = "Alpha"
locationAlpha.Address = "123 Sunflower Street, Happyville"
model.DB.Create(&locationAlpha)
fmt.Println("Created Location: Alpha")
}
// Location Beta with 2 groups
locationBeta := model.Location{}
model.DB.Where("name = ?", "Beta").First(&locationBeta)
if locationBeta.ID == 0 {
locationBeta.Name = "Beta"
locationBeta.Address = "456 Rainbow Road, Cheertown"
model.DB.Create(&locationBeta)
fmt.Println("Created Location: Beta")
}
// Alpha Groups
alphaGroups := []string{"The Bees", "The Butterflies", "The Ladybugs"}
for _, groupName := range alphaGroups {
grp := model.Group{}
model.DB.Where("name = ? AND location_id = ?", groupName, locationAlpha.ID).First(&grp)
if grp.ID == 0 {
grp.Name = groupName
grp.LocationId = locationAlpha.ID
model.DB.Create(&grp)
fmt.Printf("Created Group: %s at Alpha\n", groupName)
}
}
// Beta Groups
betaGroups := []string{"The Dolphins", "The Turtles"}
for _, groupName := range betaGroups {
grp := model.Group{}
model.DB.Where("name = ? AND location_id = ?", groupName, locationBeta.ID).First(&grp)
if grp.ID == 0 {
grp.Name = groupName
grp.LocationId = locationBeta.ID
model.DB.Create(&grp)
fmt.Printf("Created Group: %s at Beta\n", groupName)
}
}
}
func CreateChild(firstName, lastName string, birthday time.Time) model.Child {
validFrom := time.Now().AddDate(-1, 0, 0) // Started 1 year ago
validUntil := time.Now().AddDate(1, 0, 0) // Valid for 1 more year
child := model.Child{
FirstName: firstName,
LastName: lastName,
Birthday: birthday,
Active: true,
ValidFrom: &validFrom,
ValidUntil: &validUntil,
}
return child
}
func CreateChildrenAndParents() {
// Get groups
var groups []model.Group
model.DB.Preload("Location").Find(&groups)
groupMap := make(map[string]*model.Group)
for i := range groups {
groupMap[groups[i].Name] = &groups[i]
}
// Children data for each group (10 children per group)
childrenData := map[string][]struct {
FirstName string
LastName string
Birthday time.Time
}{
"The Bees": {
{"Emma", "Müller", time.Date(2019, 3, 15, 0, 0, 0, 0, time.UTC)}, // Parent: emma.mueller
{"Liam", "Schmidt", time.Date(2019, 7, 22, 0, 0, 0, 0, time.UTC)}, // Parent: liam.schmidt
{"Sophia", "Weber", time.Date(2018, 11, 8, 0, 0, 0, 0, time.UTC)}, // Parent: sophia.weber
{"Noah", "Fischer", time.Date(2019, 1, 30, 0, 0, 0, 0, time.UTC)}, // (no parent assigned yet)
{"Olivia", "Wagner", time.Date(2019, 5, 12, 0, 0, 0, 0, time.UTC)}, // Parent: olivia.wagner.alpha
{"Elijah", "Becker", time.Date(2018, 9, 3, 0, 0, 0, 0, time.UTC)}, // Parent: elijah.becker.alpha
{"Ava", "Müller", time.Date(2019, 2, 18, 0, 0, 0, 0, time.UTC)}, // Parent: emma.mueller
{"Lucas", "Hoffmann", time.Date(2019, 6, 25, 0, 0, 0, 0, time.UTC)}, // Parent: lucas.hoffmann
{"Mia", "Koch", time.Date(2018, 12, 7, 0, 0, 0, 0, time.UTC)}, // Parent: mia.koch
{"Oliver", "Richter", time.Date(2019, 4, 14, 0, 0, 0, 0, time.UTC)}, // Parent: oliver.richter
},
"The Butterflies": {
{"Isabella", "Fischer", time.Date(2019, 8, 9, 0, 0, 0, 0, time.UTC)}, // Parent: noah.fischer
{"Mason", "Schmidt", time.Date(2018, 10, 21, 0, 0, 0, 0, time.UTC)}, // Parent: liam.schmidt
{"Charlotte", "Schröder", time.Date(2019, 3, 5, 0, 0, 0, 0, time.UTC)}, // Parent: charlotte.schroeder
{"Ethan", "Neumann", time.Date(2019, 7, 17, 0, 0, 0, 0, time.UTC)}, // Parent: ethan.neumann
{"Amelia", "Schwarz", time.Date(2018, 11, 28, 0, 0, 0, 0, time.UTC)}, // Parent: amelia.schwarz
{"James", "Zimmermann", time.Date(2019, 1, 13, 0, 0, 0, 0, time.UTC)}, // Parent: james.zimmermann
{"Harper", "Braun", time.Date(2019, 5, 6, 0, 0, 0, 0, time.UTC)}, // Parent: harper.braun
{"Benjamin", "Hofmann", time.Date(2018, 9, 19, 0, 0, 0, 0, time.UTC)}, // Parent: benjamin.hofmann
{"Evelyn", "Fischer", time.Date(2019, 2, 24, 0, 0, 0, 0, time.UTC)}, // Parent: noah.fischer
{"Henry", "Lange", time.Date(2019, 6, 11, 0, 0, 0, 0, time.UTC)}, // Parent: henry.lange
},
"The Ladybugs": {
{"Abigail", "Wagner", time.Date(2019, 10, 3, 0, 0, 0, 0, time.UTC)}, // Parent: olivia.wagner
{"Alexander", "Werner", time.Date(2018, 12, 16, 0, 0, 0, 0, time.UTC)}, // Parent: alexander.werner
{"Emily", "Weber", time.Date(2019, 4, 8, 0, 0, 0, 0, time.UTC)}, // Parent: sophia.weber
{"Michael", "Meier", time.Date(2019, 8, 20, 0, 0, 0, 0, time.UTC)}, // Parent: michael.meier
{"Elizabeth", "Lehmann", time.Date(2018, 11, 1, 0, 0, 0, 0, time.UTC)}, // Parent: elizabeth.lehmann
{"Daniel", "Huber", time.Date(2019, 3, 27, 0, 0, 0, 0, time.UTC)}, // Parent: daniel.huber
{"Sofia", "König", time.Date(2019, 7, 9, 0, 0, 0, 0, time.UTC)}, // Parent: sofia.koenig
{"Matthew", "Kaiser", time.Date(2018, 10, 22, 0, 0, 0, 0, time.UTC)}, // Parent: matthew.kaiser
{"Avery", "Fuchs", time.Date(2019, 2, 14, 0, 0, 0, 0, time.UTC)}, // Parent: avery.fuchs
{"Joseph", "Herrmann", time.Date(2019, 6, 5, 0, 0, 0, 0, time.UTC)}, // Parent: joseph.herrmann
},
"The Dolphins": {
{"Ella", "Müller", time.Date(2019, 9, 12, 0, 0, 0, 0, time.UTC)}, // Parent: emma.mueller
{"David", "Becker", time.Date(2018, 11, 25, 0, 0, 0, 0, time.UTC)}, // Parent: elijah.becker
{"Scarlett", "Berger", time.Date(2019, 4, 18, 0, 0, 0, 0, time.UTC)}, // Parent: scarlett.berger
{"Jackson", "Franke", time.Date(2019, 8, 30, 0, 0, 0, 0, time.UTC)}, // Parent: jackson.franke
{"Victoria", "Albrecht", time.Date(2018, 12, 11, 0, 0, 0, 0, time.UTC)}, // Parent: victoria.albrecht
{"Sebastian", "Arnold", time.Date(2019, 3, 23, 0, 0, 0, 0, time.UTC)}, // Parent: sebastian.arnold
{"Grace", "Winter", time.Date(2019, 7, 5, 0, 0, 0, 0, time.UTC)}, // Parent: grace.winter
{"Owen", "Sommer", time.Date(2018, 10, 17, 0, 0, 0, 0, time.UTC)}, // Parent: owen.sommer
{"Chloe", "Vogt", time.Date(2019, 2, 28, 0, 0, 0, 0, time.UTC)}, // Parent: chloe.vogt
{"Luke", "Stein", time.Date(2019, 6, 10, 0, 0, 0, 0, time.UTC)}, // Parent: luke.stein
},
"The Turtles": {
{"Lily", "Wagner", time.Date(2019, 10, 7, 0, 0, 0, 0, time.UTC)}, // Parent: olivia.wagner
{"Jack", "Becker", time.Date(2018, 12, 20, 0, 0, 0, 0, time.UTC)}, // Parent: elijah.becker
{"Zoey", "Otto", time.Date(2019, 5, 2, 0, 0, 0, 0, time.UTC)}, // Parent: zoey.otto
{"Wyatt", "Sauer", time.Date(2019, 9, 14, 0, 0, 0, 0, time.UTC)}, // Parent: wyatt.sauer
{"Hannah", "Roth", time.Date(2018, 11, 26, 0, 0, 0, 0, time.UTC)}, // Parent: hannah.roth
{"Leo", "Engel", time.Date(2019, 4, 8, 0, 0, 0, 0, time.UTC)}, // Parent: leo.engel
{"Lillian", "Keller", time.Date(2019, 8, 19, 0, 0, 0, 0, time.UTC)}, // Parent: lillian.keller
{"Gabriel", "Jung", time.Date(2018, 10, 31, 0, 0, 0, 0, time.UTC)}, // Parent: gabriel.jung
{"Natalie", "Hahn", time.Date(2019, 3, 13, 0, 0, 0, 0, time.UTC)}, // Parent: natalie.hahn
{"Carter", "Vogel", time.Date(2019, 7, 25, 0, 0, 0, 0, time.UTC)}, // Parent: carter.vogel
},
}
// Parent configurations
// Format: email, password, firstName, lastName, []childrenIndices (group:index), relationshipRole
parentConfigs := []struct {
Email string
Password string
FirstName string
LastName string
Children []string // Format: "GroupName:Index"
RelationshipRole model.RelationshipRole
}{
// 1 parent with 3 children (one in different location)
{"emma.mueller@wippidu.app", "password123", "Emma", "Müller", []string{"The Bees:0", "The Bees:6", "The Dolphins:0"}, model.RelationshipMother},
// 5 parents with 2 children each
{"liam.schmidt@wippidu.app", "password123", "Liam", "Schmidt", []string{"The Bees:1", "The Butterflies:1"}, model.RelationshipFather},
{"sophia.weber@wippidu.app", "password123", "Sophia", "Weber", []string{"The Bees:2", "The Ladybugs:2"}, model.RelationshipMother},
{"noah.fischer@wippidu.app", "password123", "Noah", "Fischer", []string{"The Butterflies:0", "The Butterflies:8"}, model.RelationshipFather},
{"olivia.wagner@wippidu.app", "password123", "Olivia", "Wagner", []string{"The Ladybugs:0", "The Turtles:0"}, model.RelationshipMother},
{"elijah.becker@wippidu.app", "password123", "Elijah", "Becker", []string{"The Dolphins:1", "The Turtles:1"}, model.RelationshipFather},
// Rest of parents with 1 child each
{"james.zimmermann@wippidu.app", "password123", "James", "Zimmermann", []string{"The Butterflies:5"}, model.RelationshipFather},
{"harper.braun@wippidu.app", "password123", "Harper", "Braun", []string{"The Butterflies:6"}, model.RelationshipMother},
{"benjamin.hofmann@wippidu.app", "password123", "Benjamin", "Hofmann", []string{"The Butterflies:7"}, model.RelationshipFather},
// evelyn.hartmann removed - Butterflies:8 belongs to noah.fischer
{"henry.lange@wippidu.app", "password123", "Henry", "Lange", []string{"The Butterflies:9"}, model.RelationshipFather},
{"charlotte.schroeder@wippidu.app", "password123", "Charlotte", "Schröder", []string{"The Butterflies:2"}, model.RelationshipMother},
{"ethan.neumann@wippidu.app", "password123", "Ethan", "Neumann", []string{"The Butterflies:3"}, model.RelationshipFather},
{"amelia.schwarz@wippidu.app", "password123", "Amelia", "Schwarz", []string{"The Butterflies:4"}, model.RelationshipMother},
{"elijah.becker.alpha@wippidu.app", "password123", "Elias", "Becker", []string{"The Bees:5"}, model.RelationshipFather},
{"olivia.wagner.alpha@wippidu.app", "password123", "Olivia", "Wagner-Alpha", []string{"The Bees:4"}, model.RelationshipMother},
{"lucas.hoffmann@wippidu.app", "password123", "Lucas", "Hoffmann", []string{"The Bees:7"}, model.RelationshipFather},
{"mia.koch@wippidu.app", "password123", "Mia", "Koch", []string{"The Bees:8"}, model.RelationshipMother},
{"oliver.richter@wippidu.app", "password123", "Oliver", "Richter", []string{"The Bees:9"}, model.RelationshipFather},
{"alexander.werner@wippidu.app", "password123", "Alexander", "Werner", []string{"The Ladybugs:1"}, model.RelationshipFather},
// emily.krause removed - Ladybugs:2 belongs to sophia.weber
{"michael.meier@wippidu.app", "password123", "Michael", "Meier", []string{"The Ladybugs:3"}, model.RelationshipFather},
{"elizabeth.lehmann@wippidu.app", "password123", "Elizabeth", "Lehmann", []string{"The Ladybugs:4"}, model.RelationshipMother},
{"daniel.huber@wippidu.app", "password123", "Daniel", "Huber", []string{"The Ladybugs:5"}, model.RelationshipFather},
{"sofia.koenig@wippidu.app", "password123", "Sofia", "König", []string{"The Ladybugs:6"}, model.RelationshipMother},
{"matthew.kaiser@wippidu.app", "password123", "Matthew", "Kaiser", []string{"The Ladybugs:7"}, model.RelationshipFather},
{"avery.fuchs@wippidu.app", "password123", "Avery", "Fuchs", []string{"The Ladybugs:8"}, model.RelationshipMother},
{"joseph.herrmann@wippidu.app", "password123", "Joseph", "Herrmann", []string{"The Ladybugs:9"}, model.RelationshipFather},
{"scarlett.berger@wippidu.app", "password123", "Scarlett", "Berger", []string{"The Dolphins:2"}, model.RelationshipMother},
{"jackson.franke@wippidu.app", "password123", "Jackson", "Franke", []string{"The Dolphins:3"}, model.RelationshipFather},
{"victoria.albrecht@wippidu.app", "password123", "Victoria", "Albrecht", []string{"The Dolphins:4"}, model.RelationshipMother},
{"sebastian.arnold@wippidu.app", "password123", "Sebastian", "Arnold", []string{"The Dolphins:5"}, model.RelationshipFather},
{"grace.winter@wippidu.app", "password123", "Grace", "Winter", []string{"The Dolphins:6"}, model.RelationshipMother},
{"owen.sommer@wippidu.app", "password123", "Owen", "Sommer", []string{"The Dolphins:7"}, model.RelationshipFather},
{"chloe.vogt@wippidu.app", "password123", "Chloe", "Vogt", []string{"The Dolphins:8"}, model.RelationshipMother},
{"luke.stein@wippidu.app", "password123", "Luke", "Stein", []string{"The Dolphins:9"}, model.RelationshipFather},
{"zoey.otto@wippidu.app", "password123", "Zoey", "Otto", []string{"The Turtles:2"}, model.RelationshipMother},
{"wyatt.sauer@wippidu.app", "password123", "Wyatt", "Sauer", []string{"The Turtles:3"}, model.RelationshipFather},
{"hannah.roth@wippidu.app", "password123", "Hannah", "Roth", []string{"The Turtles:4"}, model.RelationshipMother},
{"leo.engel@wippidu.app", "password123", "Leo", "Engel", []string{"The Turtles:5"}, model.RelationshipFather},
{"lillian.keller@wippidu.app", "password123", "Lillian", "Keller", []string{"The Turtles:6"}, model.RelationshipMother},
{"gabriel.jung@wippidu.app", "password123", "Gabriel", "Jung", []string{"The Turtles:7"}, model.RelationshipFather},
{"natalie.hahn@wippidu.app", "password123", "Natalie", "Hahn", []string{"The Turtles:8"}, model.RelationshipMother},
{"carter.vogel@wippidu.app", "password123", "Carter", "Vogel", []string{"The Turtles:9"}, model.RelationshipFather},
}
// Create all children first and store references
allChildren := make(map[string][]model.Child)
for groupName, children := range childrenData {
group := groupMap[groupName]
if group == nil {
fmt.Printf("Warning: Group %s not found\n", groupName)
continue
}
allChildren[groupName] = []model.Child{}
for _, childData := range children {
existingChild := model.Child{}
model.DB.Where("first_name = ? AND last_name = ?", childData.FirstName, childData.LastName).First(&existingChild)
if existingChild.ID == 0 {
// Get validity dates based on contract status
childName := childData.FirstName + " " + childData.LastName
validFrom, validUntil, active := getChildValidityDates(childName)
child := model.Child{
FirstName: childData.FirstName,
LastName: childData.LastName,
Birthday: childData.Birthday,
Active: active,
ValidFrom: validFrom,
ValidUntil: validUntil,
GroupId: &group.ID,
}
model.DB.Create(&child)
model.DB.Where("first_name = ? AND last_name = ?", childData.FirstName, childData.LastName).First(&child)
allChildren[groupName] = append(allChildren[groupName], child)
fmt.Printf("Created child: %s %s in group %s (active=%v)\n", childData.FirstName, childData.LastName, groupName, active)
} else {
allChildren[groupName] = append(allChildren[groupName], existingChild)
}
}
}
// Create parents and assign children
parentRole := model.Role{}
model.DB.Where("name = ?", "Parent").First(&parentRole)
for _, pc := range parentConfigs {
err := CreateUserWithLogin(pc.Email, pc.Password, pc.FirstName, pc.LastName)
if err != nil {
fmt.Printf("Error creating user %s: %v\n", pc.Email, err)
continue
}
user := model.User{}
model.DB.Where("email = ?", pc.Email).First(&user)
// Assign Parent role (check if not already assigned)
var roleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, parentRole.ID).Count(&roleCount)
if roleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&parentRole)
fmt.Printf("Assigned Parent role to %s\n", pc.Email)
} else {
fmt.Printf("Parent role already assigned to %s\n", pc.Email)
}
// Assign children using UserChild model with relationship role
for _, childRef := range pc.Children {
parts := strings.Split(childRef, ":")
if len(parts) != 2 {
fmt.Printf("Warning: Invalid childRef format: %s\n", childRef)
continue
}
groupName := parts[0]
childIndex, err := strconv.Atoi(parts[1])
if err != nil {
fmt.Printf("Warning: Invalid childIndex in %s: %v\n", childRef, err)
continue
}
if children, ok := allChildren[groupName]; ok && childIndex < len(children) {
child := children[childIndex]
// Check if relationship already exists
var childCount int64
model.DB.Table("user_children").Where("user_id = ? AND child_id = ?", user.ID, child.ID).Count(&childCount)
if childCount == 0 {
// Get access validity dates based on email and child name
childName := child.FirstName + " " + child.LastName
validFrom, validUntil := getUserChildValidityDates(pc.Email, childName)
// Create UserChild relationship with role and validity dates
userChild := model.UserChild{
UserID: user.ID,
ChildID: child.ID,
RelationshipRole: pc.RelationshipRole,
ValidFrom: validFrom,
ValidUntil: validUntil,
}
model.DB.Create(&userChild)
accessInfo := "unlimited"
if validFrom != nil || validUntil != nil {
accessInfo = "custom dates"
}
fmt.Printf("Assigned child %s %s to parent %s (role: %s, access: %s)\n", child.FirstName, child.LastName, pc.Email, pc.RelationshipRole, accessInfo)
} else {
fmt.Printf("Child %s %s already assigned to parent %s\n", child.FirstName, child.LastName, pc.Email)
}
}
}
}
}
func CreateEmployees() {
// Get groups
var groups []model.Group
model.DB.Preload("Location").Find(&groups)
employeeRole := model.Role{}
model.DB.Where("name = ?", "Employee").First(&employeeRole)
groupLeadRole := model.Role{}
model.DB.Where("name = ?", "GroupLead").First(&groupLeadRole)
locationLeadRole := model.Role{}
model.DB.Where("name = ?", "LocationLead").First(&locationLeadRole)
// Create employees and assign to groups
// Format: Email, Password, FirstName, LastName, Groups, AdditionalRoles
employees := []struct {
Email string
Password string
FirstName string
LastName string
Groups []string
AdditionalRoles []string // Can be "GroupLead" or "LocationLead"
}{
{"teacher.bees@wippidu.app", "teacher123", "Birgit", "Bienenfreund", []string{"The Bees"}, []string{"GroupLead"}},
{"teacher.butterflies@wippidu.app", "teacher123", "Sabine", "Schmetterlingsfrau", []string{"The Butterflies"}, []string{"GroupLead"}},
{"teacher.ladybugs@wippidu.app", "teacher123", "Lena", "Käferfreundin", []string{"The Ladybugs"}, []string{"GroupLead"}},
{"teacher.dolphins@wippidu.app", "teacher123", "Doris", "Delphintrainerin", []string{"The Dolphins"}, []string{"GroupLead"}},
{"teacher.turtles@wippidu.app", "teacher123", "Tanja", "Schildkrötenpflegerin", []string{"The Turtles"}, []string{"GroupLead"}},
{"teacher.alpha@wippidu.app", "teacher123", "Anna", "Alphaleitung", []string{"The Bees", "The Butterflies", "The Ladybugs"}, []string{"LocationLead"}},
{"teacher.beta@wippidu.app", "teacher123", "Bernd", "Betaleiter", []string{"The Dolphins", "The Turtles"}, []string{"LocationLead"}},
// Regular employees with no additional roles - 2 per group
{"employee.bees1@wippidu.app", "employee123", "Bruno", "Bienenhelfer", []string{"The Bees"}, []string{}},
{"employee.bees2@wippidu.app", "employee123", "Bettina", "Blumenpflückerin", []string{"The Bees"}, []string{}},
{"employee.butterflies1@wippidu.app", "employee123", "Sascha", "Sonnenschein", []string{"The Butterflies"}, []string{}},
{"employee.butterflies2@wippidu.app", "employee123", "Simone", "Sommerwind", []string{"The Butterflies"}, []string{}},
{"employee.ladybugs1@wippidu.app", "employee123", "Lars", "Laubfrosch", []string{"The Ladybugs"}, []string{}},
{"employee.ladybugs2@wippidu.app", "employee123", "Lara", "Lichtblick", []string{"The Ladybugs"}, []string{}},
{"employee.dolphins1@wippidu.app", "employee123", "Dennis", "Delfinschwimmer", []string{"The Dolphins"}, []string{}},
{"employee.dolphins2@wippidu.app", "employee123", "Diana", "Delfinflüsterin", []string{"The Dolphins"}, []string{}},
{"employee.turtles1@wippidu.app", "employee123", "Tim", "Teichbewohner", []string{"The Turtles"}, []string{}},
{"employee.turtles2@wippidu.app", "employee123", "Tina", "Tropfenbringerin", []string{"The Turtles"}, []string{}},
}
for _, emp := range employees {
err := CreateUserWithLogin(emp.Email, emp.Password, emp.FirstName, emp.LastName)
if err != nil {
fmt.Printf("Error creating employee %s: %v\n", emp.Email, err)
continue
}
user := model.User{}
model.DB.Where("email = ?", emp.Email).First(&user)
// Assign Employee role (check if not already assigned)
var roleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, employeeRole.ID).Count(&roleCount)
if roleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&employeeRole)
fmt.Printf("Assigned Employee role to %s\n", emp.Email)
} else {
fmt.Printf("Employee role already assigned to %s\n", emp.Email)
}
// Assign additional roles (GroupLead or LocationLead)
for _, roleName := range emp.AdditionalRoles {
var additionalRole model.Role
if roleName == "GroupLead" {
additionalRole = groupLeadRole
} else if roleName == "LocationLead" {
additionalRole = locationLeadRole
}
if additionalRole.ID > 0 {
var additionalRoleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, additionalRole.ID).Count(&additionalRoleCount)
if additionalRoleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&additionalRole)
fmt.Printf("Assigned %s role to %s\n", roleName, emp.Email)
} else {
fmt.Printf("%s role already assigned to %s\n", roleName, emp.Email)
}
}
}
// Assign to groups
for _, groupName := range emp.Groups {
for _, group := range groups {
if group.Name == groupName {
model.DB.Table("group_teachers").Where("group_id = ? AND user_id = ?", group.ID, user.ID).FirstOrCreate(&struct {
GroupId uint
UserId uint
}{GroupId: group.ID, UserId: user.ID})
fmt.Printf("Assigned employee %s to group %s\n", emp.Email, groupName)
}
}
}
}
}
func CreateDualRoleUsers() {
// Get roles
parentRole := model.Role{}
model.DB.Where("name = ?", "Parent").First(&parentRole)
employeeRole := model.Role{}
model.DB.Where("name = ?", "Employee").First(&employeeRole)
// Get groups
var beesGroup model.Group
model.DB.Where("name = ?", "The Bees").Preload("Location").First(&beesGroup)
var dolphinsGroup model.Group
model.DB.Where("name = ?", "The Dolphins").Preload("Location").First(&dolphinsGroup)
// Dual-role user configurations
// These users have both Parent AND Employee roles
dualRoleUsers := []struct {
Email string
Password string
FirstName string
LastName string
GroupName string
ChildName string // "FirstName LastName" of existing child to assign
}{
{"dual.alpha@wippidu.app", "password123", "Maria", "Doppelrolle", "The Bees", "Noah Fischer"},
{"dual.beta@wippidu.app", "password123", "Thomas", "Doppelrolle", "The Dolphins", "Scarlett Berger"},
}
for _, du := range dualRoleUsers {
err := CreateUserWithLogin(du.Email, du.Password, du.FirstName, du.LastName)
if err != nil {
fmt.Printf("Error creating dual-role user %s: %v\n", du.Email, err)
continue
}
user := model.User{}
model.DB.Where("email = ?", du.Email).First(&user)
// Assign Parent role
var parentRoleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, parentRole.ID).Count(&parentRoleCount)
if parentRoleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&parentRole)
fmt.Printf("Assigned Parent role to %s\n", du.Email)
}
// Assign Employee role
var employeeRoleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, employeeRole.ID).Count(&employeeRoleCount)
if employeeRoleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&employeeRole)
fmt.Printf("Assigned Employee role to %s\n", du.Email)
}
// Assign child (find existing child by name) using UserChild model
nameParts := strings.Split(du.ChildName, " ")
if len(nameParts) == 2 {
child := model.Child{}
model.DB.Where("first_name = ? AND last_name = ?", nameParts[0], nameParts[1]).First(&child)
if child.ID > 0 {
var childCount int64
model.DB.Table("user_children").Where("user_id = ? AND child_id = ?", user.ID, child.ID).Count(&childCount)
if childCount == 0 {
// Create UserChild relationship (dual-role users default to "other" relationship)
userChild := model.UserChild{
UserID: user.ID,
ChildID: child.ID,
RelationshipRole: model.RelationshipOther, // Dual-role users use "other" as default
// ValidFrom and ValidUntil are nil = unlimited access
}
model.DB.Create(&userChild)
fmt.Printf("Assigned child %s to dual-role user %s (role: %s)\n", du.ChildName, du.Email, model.RelationshipOther)
}
}
}
// Assign to group as teacher
var group model.Group
model.DB.Where("name = ?", du.GroupName).First(&group)
if group.ID > 0 {
model.DB.Table("group_teachers").Where("group_id = ? AND user_id = ?", group.ID, user.ID).FirstOrCreate(&struct {
GroupId uint
UserId uint
}{GroupId: group.ID, UserId: user.ID})
fmt.Printf("Assigned dual-role user %s to group %s as teacher\n", du.Email, du.GroupName)
}
}
}
func CreateParentalLetters() {
// Get necessary data
var beesGroup model.Group
model.DB.Where("name = ?", "The Bees").First(&beesGroup)
var butterfliesGroup model.Group
model.DB.Where("name = ?", "The Butterflies").First(&butterfliesGroup)
var locationAlpha model.Location
model.DB.Where("name = ?", "Alpha").First(&locationAlpha)
var teacherBees model.User
model.DB.Where("email = ?", "teacher.bees@wippidu.app").First(&teacherBees)
var teacherButterflies model.User
model.DB.Where("email = ?", "teacher.butterflies@wippidu.app").First(&teacherButterflies)
var teacherAlpha model.User
model.DB.Where("email = ?", "teacher.alpha@wippidu.app").First(&teacherAlpha)
// Letter 1: For "The Bees" group (published 3 days ago)
publishedAt1 := time.Now().AddDate(0, 0, -3)
letter1 := model.ParentalLetter{
CreatedById: teacherBees.ID,
LocationId: beesGroup.LocationId,
GroupId: &beesGroup.ID,
Subject: "Upcoming Farm Field Trip - Next Friday",
Text: "Dear parents of The Bees,\n\nWe are excited to announce our upcoming field trip to the local farm! The trip is scheduled for next Friday.\n\nPlease ensure your child brings:\n- Comfortable walking shoes\n- A water bottle\n- Sun protection (hat and sunscreen)\n\nWe will depart at 9:00 AM and return by 2:00 PM.\n\nBest regards,\nThe Bees Team",
Draft: false,
ReviewStatus: "published",
PublishedAt: &publishedAt1,
}
var existingLetter1 model.ParentalLetter
model.DB.Where("group_id = ? AND text LIKE ?", beesGroup.ID, "Dear parents of The Bees,%").First(&existingLetter1)
if existingLetter1.ID == 0 {
model.DB.Create(&letter1)
fmt.Println("Created parental letter for The Bees group")
// Mark as read by 2 parents
var emmaUser model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaUser)
var sophiaUser model.User
model.DB.Where("email = ?", "sophia.weber@wippidu.app").First(&sophiaUser)
readAt := time.Now().AddDate(0, 0, -2)
if emmaUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter1.ID,
UserId: emmaUser.ID,
ReadAt: &readAt,
})
fmt.Println("Marked letter as read by emma.mueller@wippidu.app")
}
readAt2 := time.Now().AddDate(0, 0, -1)
if sophiaUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter1.ID,
UserId: sophiaUser.ID,
ReadAt: &readAt2,
})
fmt.Println("Marked letter as read by sophia.weber@wippidu.app")
}
} else {
fmt.Println("Parental letter for The Bees group already exists")
}
// Letter 2: For "The Butterflies" group (published 1 day ago)
publishedAt2 := time.Now().AddDate(0, 0, -1)
letter2 := model.ParentalLetter{
CreatedById: teacherButterflies.ID,
LocationId: butterfliesGroup.LocationId,
GroupId: &butterfliesGroup.ID,
Subject: "Parent-Teacher Conferences Next Week",
Text: "Dear Butterflies parents,\n\nReminder: Parent-teacher conferences are scheduled for next week. Please check your email for your individual time slot.\n\nIf you haven't received your appointment time or need to reschedule, please contact us as soon as possible.\n\nWe look forward to discussing your child's progress!\n\nWarm regards,\nThe Butterflies Team",
Draft: false,
ReviewStatus: "published",
PublishedAt: &publishedAt2,
}
var existingLetter2 model.ParentalLetter
model.DB.Where("group_id = ? AND text LIKE ?", butterfliesGroup.ID, "Dear Butterflies parents,%").First(&existingLetter2)
if existingLetter2.ID == 0 {
model.DB.Create(&letter2)
fmt.Println("Created parental letter for The Butterflies group")
// Mark as read by 1 parent
var noahUser model.User
model.DB.Where("email = ?", "noah.fischer@wippidu.app").First(&noahUser)
readAt := time.Now().AddDate(0, 0, 0).Add(-3 * time.Hour)
if noahUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter2.ID,
UserId: noahUser.ID,
ReadAt: &readAt,
})
fmt.Println("Marked letter as read by noah.fischer@wippidu.app")
}
} else {
fmt.Println("Parental letter for The Butterflies group already exists")
}
// Letter 3: Location-wide letter for "Alpha" (published 5 days ago, no one read it)
publishedAt3 := time.Now().AddDate(0, 0, -5)
letter3 := model.ParentalLetter{
CreatedById: teacherAlpha.ID,
LocationId: locationAlpha.ID,
GroupId: nil, // Location-wide letter
Subject: "Facility Improvements During Holiday Break",
Text: "Dear all parents at Alpha location,\n\nWe are pleased to announce that our facility will undergo some exciting improvements during the upcoming holiday break:\n\n- Fresh paint in all classrooms\n- New playground equipment\n- Upgraded kitchen facilities\n\nThese improvements will not affect our regular schedule. We will continue operations as normal.\n\nThank you for your continued trust in our facility.\n\nBest regards,\nAlpha Location Management",
Draft: false,
ReviewStatus: "published",
PublishedAt: &publishedAt3,
}
var existingLetter3 model.ParentalLetter
model.DB.Where("location_id = ? AND group_id IS NULL AND text LIKE ?", locationAlpha.ID, "Dear all parents at Alpha location,%").First(&existingLetter3)
if existingLetter3.ID == 0 {
model.DB.Create(&letter3)
fmt.Println("Created location-wide parental letter for Alpha")
} else {
fmt.Println("Location-wide parental letter for Alpha already exists")
}
// Letter 4: For "The Bees" group with answer_required (published 2 days ago)
publishedAt4 := time.Now().AddDate(0, 0, -2)
deadline4 := time.Now().AddDate(0, 0, 3) // Deadline in 3 days
letter4 := model.ParentalLetter{
CreatedById: teacherBees.ID,
LocationId: beesGroup.LocationId,
GroupId: &beesGroup.ID,
Subject: "RSVP Required: Summer Festival Participation",
Text: "Dear parents of The Bees,\n\nWe are organizing our annual Summer Festival and need to know if your child will be participating.\n\nPlease confirm your child's attendance by responding to this letter. We need your response to plan activities and catering.\n\nDetails:\n- Date: Next Saturday\n- Time: 10:00 AM - 3:00 PM\n- Location: Daycare garden\n\nPlease respond with YES or NO, and mention any dietary restrictions if applicable.\n\nThank you!\nThe Bees Team",
InteractionType: "answer_required",
Deadline: &deadline4,
Draft: false,
ReviewStatus: "published",
PublishedAt: &publishedAt4,
}
var existingLetter4 model.ParentalLetter
model.DB.Where("group_id = ? AND subject = ?", beesGroup.ID, "RSVP Required: Summer Festival Participation").First(&existingLetter4)
if existingLetter4.ID == 0 {
model.DB.Create(&letter4)
fmt.Println("Created parental letter with answer_required for The Bees group")
// Mark as read and answered by 1 parent
var emmaUser model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaUser)
readAt := time.Now().AddDate(0, 0, -1)
answeredAt := time.Now().AddDate(0, 0, -1).Add(2 * time.Hour)
answerText := "YES, Emma will participate. No dietary restrictions."
if emmaUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter4.ID,
UserId: emmaUser.ID,
ReadAt: &readAt,
Answer: &answerText,
AnsweredAt: &answeredAt,
})
fmt.Println("Marked letter as read and answered by emma.mueller@wippidu.app")
}
} else {
fmt.Println("Parental letter with answer_required for The Bees group already exists")
}
// Letter 5: For "The Bees" group with answer_possible (published 4 days ago)
publishedAt5 := time.Now().AddDate(0, 0, -4)
letter5 := model.ParentalLetter{
CreatedById: teacherBees.ID,
LocationId: beesGroup.LocationId,
GroupId: &beesGroup.ID,
Subject: "Feedback Welcome: New Outdoor Play Equipment",
Text: "Dear parents of The Bees,\n\nWe have recently installed new outdoor play equipment in our garden area. The children are very excited about the new climbing frames and slides!\n\nIf you have any feedback or suggestions about the new equipment, we would love to hear from you. Your input is always valuable to us.\n\nFeel free to share your thoughts, but a response is not required.\n\nBest regards,\nThe Bees Team",
InteractionType: "answer_possible",
Deadline: nil,
Draft: false,
ReviewStatus: "published",
PublishedAt: &publishedAt5,
}
var existingLetter5 model.ParentalLetter
model.DB.Where("group_id = ? AND subject = ?", beesGroup.ID, "Feedback Welcome: New Outdoor Play Equipment").First(&existingLetter5)
if existingLetter5.ID == 0 {
model.DB.Create(&letter5)
fmt.Println("Created parental letter with answer_possible for The Bees group")
// Mark as read by 2 parents, one with answer
var emmaUser model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaUser)
var liamUser model.User
model.DB.Where("email = ?", "liam.schmidt@wippidu.app").First(&liamUser)
readAt1 := time.Now().AddDate(0, 0, -3)
answeredAt1 := time.Now().AddDate(0, 0, -3).Add(4 * time.Hour)
answerText1 := "The new equipment looks great! Emma loves the climbing frame. Thank you for the investment!"
if emmaUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter5.ID,
UserId: emmaUser.ID,
ReadAt: &readAt1,
Answer: &answerText1,
AnsweredAt: &answeredAt1,
})
fmt.Println("Marked letter as read and answered by emma.mueller@wippidu.app")
}
readAt2 := time.Now().AddDate(0, 0, -2)
if liamUser.ID > 0 {
model.DB.Create(&model.ParentalLetterRead{
LetterId: letter5.ID,
UserId: liamUser.ID,
ReadAt: &readAt2,
// No answer - just read
})
fmt.Println("Marked letter as read (no answer) by liam.schmidt@wippidu.app")
}
} else {
fmt.Println("Parental letter with answer_possible for The Bees group already exists")
}
}
func CreateNews() {
// Get necessary data
var beesGroup model.Group
model.DB.Where("name = ?", "The Bees").First(&beesGroup)
var butterfliesGroup model.Group
model.DB.Where("name = ?", "The Butterflies").First(&butterfliesGroup)
var locationAlpha model.Location
model.DB.Where("name = ?", "Alpha").First(&locationAlpha)
var locationBeta model.Location
model.DB.Where("name = ?", "Beta").First(&locationBeta)
var teacherBees model.User
model.DB.Where("email = ?", "teacher.bees@wippidu.app").First(&teacherBees)
var teacherAlpha model.User
model.DB.Where("email = ?", "teacher.alpha@wippidu.app").First(&teacherAlpha)
var teacherBeta model.User
model.DB.Where("email = ?", "teacher.beta@wippidu.app").First(&teacherBeta)
// News 1: For "The Bees" group (published 5 days ago) - READ by emma
publishedAt1 := time.Now().AddDate(0, 0, -5)
news1 := model.News{
CreatedById: teacherBees.ID,
LocationId: uintPtr(beesGroup.LocationId),
GroupId: &beesGroup.ID,
Title: "Weekly Menu Update",
Text: "Dear Bees parents,\n\nStarting next week, we'll be introducing new healthy lunch options based on your feedback. The menu will include:\n\n- Monday: Vegetable pasta with tomato sauce\n- Tuesday: Chicken nuggets with sweet potato fries\n- Wednesday: Veggie wraps\n- Thursday: Fish sticks with rice\n- Friday: Pizza day!\n\nAll meals come with fresh fruit and milk.\n\nBest regards,\nThe Bees Team",
PublishedAt: publishedAt1,
}
var existingNews1 model.News
model.DB.Where("group_id = ? AND title = ?", beesGroup.ID, "Weekly Menu Update").First(&existingNews1)
if existingNews1.ID == 0 {
model.DB.Create(&news1)
fmt.Println("Created news 'Weekly Menu Update' for The Bees group")
// Mark as read by emma
var emmaUser model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaUser)
readAt := time.Now().AddDate(0, 0, -4)
if emmaUser.ID > 0 {
model.DB.Create(&model.NewsRead{
NewsId: news1.ID,
UserId: emmaUser.ID,
ReadAt: &readAt,
})
fmt.Println("Marked news as read by emma.mueller@wippidu.app")
}
} else {
fmt.Println("News 'Weekly Menu Update' for The Bees group already exists")
}
// News 2: For "The Bees" group (published 1 day ago) - UNREAD by emma
publishedAt2 := time.Now().AddDate(0, 0, -1)
news2 := model.News{
CreatedById: teacherBees.ID,
LocationId: uintPtr(beesGroup.LocationId),
GroupId: &beesGroup.ID,
Title: "Reminder: Parent Meeting Tomorrow",
Text: "Dear Bees parents,\n\nQuick reminder about our parent meeting tomorrow evening at 6:00 PM.\n\nTopics:\n- Upcoming events\n- Summer program planning\n- Q&A session\n\nSnacks and refreshments will be provided. We look forward to seeing you there!\n\nBest regards,\nThe Bees Team",
PublishedAt: publishedAt2,
}
var existingNews2 model.News
model.DB.Where("group_id = ? AND title = ?", beesGroup.ID, "Reminder: Parent Meeting Tomorrow").First(&existingNews2)
if existingNews2.ID == 0 {
model.DB.Create(&news2)
fmt.Println("Created news 'Reminder: Parent Meeting Tomorrow' for The Bees group (unread)")
} else {
fmt.Println("News 'Reminder: Parent Meeting Tomorrow' for The Bees group already exists")
}
// News 3: Location-wide for Alpha (published 3 days ago) - READ by emma
publishedAt3 := time.Now().AddDate(0, 0, -3)
news3 := model.News{
CreatedById: teacherAlpha.ID,
LocationId: uintPtr(locationAlpha.ID),
GroupId: nil, // Location-wide
Title: "New Parking Regulations",
Text: "Dear all parents at Alpha location,\n\nPlease note the new parking regulations starting next Monday:\n\n- Drop-off zone: Use only for quick drop-offs (max 5 minutes)\n- Long-term parking: Available in the rear parking lot\n- Please do not park in front of the emergency exit\n\nThank you for your cooperation in keeping our facility safe and accessible.\n\nBest regards,\nAlpha Location Management",
PublishedAt: publishedAt3,
}
var existingNews3 model.News
model.DB.Where("location_id = ? AND group_id IS NULL AND title = ?", locationAlpha.ID, "New Parking Regulations").First(&existingNews3)
if existingNews3.ID == 0 {
model.DB.Create(&news3)
fmt.Println("Created location-wide news 'New Parking Regulations' for Alpha")
// Mark as read by emma
var emmaUser model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaUser)
readAt := time.Now().AddDate(0, 0, -2)
if emmaUser.ID > 0 {
model.DB.Create(&model.NewsRead{
NewsId: news3.ID,
UserId: emmaUser.ID,
ReadAt: &readAt,
})
fmt.Println("Marked location-wide news as read by emma.mueller@wippidu.app")
}
} else {
fmt.Println("Location-wide news 'New Parking Regulations' for Alpha already exists")
}
// News 4: Location-wide for Alpha (published today) - UNREAD
publishedAt4 := time.Now().Add(-2 * time.Hour)
validUntil4 := time.Now().AddDate(0, 0, 7)
news4 := model.News{
CreatedById: teacherAlpha.ID,
LocationId: uintPtr(locationAlpha.ID),
GroupId: nil, // Location-wide
Title: "Holiday Closure Dates",
Text: "Dear all parents at Alpha location,\n\nPlease mark your calendars for our upcoming holiday closures:\n\n- December 24-26: Christmas closure\n- December 31 - January 1: New Year closure\n- January 6: Staff training day\n\nWe will be open with regular hours on all other days during the holiday season.\n\nHappy holidays!\nAlpha Location Management",
PublishedAt: publishedAt4,
ValidUntil: &validUntil4,
}
var existingNews4 model.News
model.DB.Where("location_id = ? AND group_id IS NULL AND title = ?", locationAlpha.ID, "Holiday Closure Dates").First(&existingNews4)
if existingNews4.ID == 0 {
model.DB.Create(&news4)
fmt.Println("Created location-wide news 'Holiday Closure Dates' for Alpha (unread)")
} else {
fmt.Println("Location-wide news 'Holiday Closure Dates' for Alpha already exists")
}
// News 5: For "The Butterflies" group (published 2 days ago)
publishedAt5 := time.Now().AddDate(0, 0, -2)
news5 := model.News{
CreatedById: teacherAlpha.ID, // Location lead posting for group
LocationId: uintPtr(butterfliesGroup.LocationId),
GroupId: &butterfliesGroup.ID,
Title: "Art Exhibition Next Week",
Text: "Dear Butterflies parents,\n\nWe're excited to invite you to our children's art exhibition next week!\n\nDate: Next Thursday\nTime: 4:00 PM - 6:00 PM\nLocation: Main hall\n\nCome see the wonderful artwork your children have been creating. Light refreshments will be served.\n\nWarm regards,\nThe Butterflies Team",
PublishedAt: publishedAt5,
}
var existingNews5 model.News
model.DB.Where("group_id = ? AND title = ?", butterfliesGroup.ID, "Art Exhibition Next Week").First(&existingNews5)
if existingNews5.ID == 0 {
model.DB.Create(&news5)
fmt.Println("Created news 'Art Exhibition Next Week' for The Butterflies group")
} else {
fmt.Println("News 'Art Exhibition Next Week' for The Butterflies group already exists")
}
// News 6: Location-wide for Beta (published 6 days ago)
publishedAt6 := time.Now().AddDate(0, 0, -6)
news6 := model.News{
CreatedById: teacherBeta.ID,
LocationId: uintPtr(locationBeta.ID),
GroupId: nil, // Location-wide
Title: "Welcome to Our New Playground!",
Text: "Dear all parents at Beta location,\n\nWe are thrilled to announce that our new playground is now open!\n\nNew features include:\n- Modern climbing structure\n- Safety-certified slides\n- Sensory play area\n- Shaded seating for supervision\n\nAll equipment meets current safety standards and has been thoroughly tested.\n\nWe hope your children enjoy the new facilities!\n\nBest regards,\nBeta Location Management",
PublishedAt: publishedAt6,
}
var existingNews6 model.News
model.DB.Where("location_id = ? AND group_id IS NULL AND title = ?", locationBeta.ID, "Welcome to Our New Playground!").First(&existingNews6)
if existingNews6.ID == 0 {
model.DB.Create(&news6)
fmt.Println("Created location-wide news 'Welcome to Our New Playground!' for Beta")
} else {
fmt.Println("Location-wide news 'Welcome to Our New Playground!' for Beta already exists")
}
}
// CreateSecondParents adds second parents (spouses) to some children
// so we have test data where both mother and father are present
func CreateSecondParents() {
parentRole := model.Role{}
model.DB.Where("name = ?", "Parent").First(&parentRole)
// Second parent configurations - adding spouse to existing children
// Format: email, password, firstName, lastName, childName (FirstName LastName), relationshipRole
secondParentConfigs := []struct {
Email string
Password string
FirstName string
LastName string
ChildName string // "FirstName LastName" of existing child
RelationshipRole model.RelationshipRole
}{
// Alpha Location - Add fathers to children who have mothers
// Emma Müller's children (emma.mueller is mother) - add father
{"max.mueller@wippidu.app", "password123", "Max", "Müller", "Emma Müller", model.RelationshipFather},
{"max.mueller@wippidu.app", "password123", "Max", "Müller", "Ava Müller", model.RelationshipFather},
// Sophia Weber's children (sophia.weber is mother) - add father
{"thomas.weber@wippidu.app", "password123", "Thomas", "Weber", "Sophia Weber", model.RelationshipFather},
{"thomas.weber@wippidu.app", "password123", "Thomas", "Weber", "Emily Weber", model.RelationshipFather},
// Olivia Wagner's children (olivia.wagner is mother) - add father
{"peter.wagner@wippidu.app", "password123", "Peter", "Wagner", "Abigail Wagner", model.RelationshipFather},
// Mia Koch's child (mia.koch is mother) - add father
{"stefan.koch@wippidu.app", "password123", "Stefan", "Koch", "Mia Koch", model.RelationshipFather},
// Alpha Location - Add mothers to children who have fathers
// Liam Schmidt's children (liam.schmidt is father) - add mother
{"maria.schmidt@wippidu.app", "password123", "Maria", "Schmidt", "Liam Schmidt", model.RelationshipMother},
{"maria.schmidt@wippidu.app", "password123", "Maria", "Schmidt", "Mason Schmidt", model.RelationshipMother},
// Noah Fischer's children (noah.fischer is father) - add mother
{"anna.fischer@wippidu.app", "password123", "Anna", "Fischer", "Isabella Fischer", model.RelationshipMother},
{"anna.fischer@wippidu.app", "password123", "Anna", "Fischer", "Evelyn Fischer", model.RelationshipMother},
// Oliver Richter's child (oliver.richter is father) - add mother
{"lisa.richter@wippidu.app", "password123", "Lisa", "Richter", "Oliver Richter", model.RelationshipMother},
// Beta Location - Add mothers to children who have fathers
// Elijah Becker's children (elijah.becker is father) - add mother
{"julia.becker@wippidu.app", "password123", "Julia", "Becker", "David Becker", model.RelationshipMother},
{"julia.becker@wippidu.app", "password123", "Julia", "Becker", "Jack Becker", model.RelationshipMother},
// Jackson Franke's child (jackson.franke is father) - add mother
{"sandra.franke@wippidu.app", "password123", "Sandra", "Franke", "Jackson Franke", model.RelationshipMother},
// Luke Stein's child (luke.stein is father) - add mother
{"claudia.stein@wippidu.app", "password123", "Claudia", "Stein", "Luke Stein", model.RelationshipMother},
// Beta Location - Add fathers to children who have mothers
// Olivia Wagner's other child at Beta (olivia.wagner is mother) - add father (same as Abigail's father)
{"peter.wagner@wippidu.app", "password123", "Peter", "Wagner", "Lily Wagner", model.RelationshipFather},
// Grace Winter's child (grace.winter is mother) - add father
{"markus.winter@wippidu.app", "password123", "Markus", "Winter", "Grace Winter", model.RelationshipFather},
// Zoey Otto's child (zoey.otto is mother) - add father
{"jan.otto@wippidu.app", "password123", "Jan", "Otto", "Zoey Otto", model.RelationshipFather},
}
for _, pc := range secondParentConfigs {
// Find the child
nameParts := strings.Split(pc.ChildName, " ")
if len(nameParts) != 2 {
fmt.Printf("Warning: Invalid child name format: %s\n", pc.ChildName)
continue
}
child := model.Child{}
model.DB.Where("first_name = ? AND last_name = ?", nameParts[0], nameParts[1]).First(&child)
if child.ID == 0 {
fmt.Printf("Warning: Child not found: %s\n", pc.ChildName)
continue
}
// Create or get user
err := CreateUserWithLogin(pc.Email, pc.Password, pc.FirstName, pc.LastName)
if err != nil {
fmt.Printf("Error creating second parent %s: %v\n", pc.Email, err)
continue
}
user := model.User{}
model.DB.Where("email = ?", pc.Email).First(&user)
// Assign Parent role
var roleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", user.ID, parentRole.ID).Count(&roleCount)
if roleCount == 0 {
model.DB.Model(&user).Association("Roles").Append(&parentRole)
fmt.Printf("Assigned Parent role to %s\n", pc.Email)
}
// Create UserChild relationship
var childCount int64
model.DB.Table("user_children").Where("user_id = ? AND child_id = ?", user.ID, child.ID).Count(&childCount)
if childCount == 0 {
userChild := model.UserChild{
UserID: user.ID,
ChildID: child.ID,
RelationshipRole: pc.RelationshipRole,
}
model.DB.Create(&userChild)
fmt.Printf("Assigned child %s to second parent %s (role: %s)\n", pc.ChildName, pc.Email, pc.RelationshipRole)
} else {
fmt.Printf("Child %s already assigned to %s\n", pc.ChildName, pc.Email)
}
}
}
func TestingInit() {
testing := os.Getenv("APP_TESTING")
if testing != "true" {
return
}
// Use SQL dump loading by default (fastest method)
// Set APP_TESTING_SLOW=true to use the original slow method (for debugging/regenerating dump)
if os.Getenv("APP_TESTING_SLOW") != "true" {
TestingInitFromSQL()
return
}
fmt.Println("=== Initializing Test Data (Slow Mode) ===")
CreateLocationsAndGroups()
CreateChildrenAndParents()
CreateEmployees()
CreateDualRoleUsers()
CreateSecondParents()
CreateParentalLetters()
CreateNews()
CreateMessages()
CreateCalendarEvents()
CreateAbsenceNotifications()
// Create admin user
CreateUserWithLogin("admin@wippidu.app", "adminsecret", "Admin", "Wippidu")
adminUser := model.User{}
model.DB.Where("email = ?", "admin@wippidu.app").First(&adminUser)
adminRole := model.Role{}
model.DB.Where("name = ?", "Admin").First(&adminRole)
// Assign Admin role (check if not already assigned)
var roleCount int64
model.DB.Table("user_roles").Where("user_id = ? AND role_id = ?", adminUser.ID, adminRole.ID).Count(&roleCount)
if roleCount == 0 {
model.DB.Model(&adminUser).Association("Roles").Append(&adminRole)
fmt.Printf("Assigned Admin role to admin@wippidu.app\n")
} else {
fmt.Printf("Admin role already assigned to admin@wippidu.app\n")
}
EnableNotifyByEmail()
fmt.Println("=== Test Data Initialization Complete ===")
}
// EnableNotifyByEmail sets NotifyByEmail=true on a few test users for integration testing
func EnableNotifyByEmail() {
emails := []string{
"admin@wippidu.app",
"emma.mueller@wippidu.app",
"liam.schmidt@wippidu.app",
}
for _, e := range emails {
var user model.User
if model.DB.Where("email = ?", e).First(&user).Error == nil {
user.NotifyByEmail = true
model.DB.Save(&user)
fmt.Printf("Enabled NotifyByEmail for %s\n", e)
}
}
}
// CreateMessages creates test parent-teacher messages for various children
func CreateMessages() {
fmt.Println("Creating test messages...")
// Get teachers
var teacherBees model.User
model.DB.Where("email = ?", "teacher.bees@wippidu.app").First(&teacherBees)
var teacherButterflies model.User
model.DB.Where("email = ?", "teacher.butterflies@wippidu.app").First(&teacherButterflies)
var teacherDolphins model.User
model.DB.Where("email = ?", "teacher.dolphins@wippidu.app").First(&teacherDolphins)
// Get some children with their parents
var emmaMueller model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Emma", "Müller").First(&emmaMueller)
var liamSchmidt model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Liam", "Schmidt").First(&liamSchmidt)
var masonSchmidt model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Mason", "Schmidt").First(&masonSchmidt)
var ellaMueller model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Ella", "Müller").First(&ellaMueller)
var lucasHoffmann model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Lucas", "Hoffmann").First(&lucasHoffmann)
// Get parents
var emmaParent model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaParent)
var liamParent model.User
model.DB.Where("email = ?", "liam.schmidt@wippidu.app").First(&liamParent)
var lucasParent model.User
model.DB.Where("email = ?", "lucas.hoffmann@wippidu.app").First(&lucasParent)
now := time.Now()
// Message 1: About Emma Müller - informal, published 3 weeks ago
publishedAt1 := now.AddDate(0, 0, -21)
msg1 := model.Message{
CreatedById: teacherBees.ID,
ChildId: emmaMueller.ID,
Subject: "Emma's Art Project Progress",
Text: "Dear parents,\n\nI wanted to share some wonderful news about Emma's progress in our art activities. She has shown remarkable creativity in our recent painting sessions and her color mixing skills have improved significantly.\n\nEmma particularly enjoys working with watercolors and has been helping other children learn the techniques as well.\n\nKeep up the great work!\n\nBest regards,\nBirgit",
InteractionType: "informal",
Draft: false,
PublishedAt: &publishedAt1,
}
var existingMsg1 model.Message
model.DB.Where("child_id = ? AND subject = ?", emmaMueller.ID, "Emma's Art Project Progress").First(&existingMsg1)
if existingMsg1.ID == 0 {
model.DB.Create(&msg1)
// Add recipient
model.DB.Model(&msg1).Association("Recipients").Append(&emmaParent)
fmt.Println("Created message 'Emma's Art Project Progress'")
// Mark as read
readAt := now.AddDate(0, 0, -20)
model.DB.Create(&model.MessageRead{
MessageId: msg1.ID,
UserId: emmaParent.ID,
ReadAt: &readAt,
})
}
// Message 2: About Liam Schmidt - answer_required, published 2 weeks ago, deadline in past
publishedAt2 := now.AddDate(0, 0, -14)
deadline2 := now.AddDate(0, 0, -7)
msg2 := model.Message{
CreatedById: teacherBees.ID,
ChildId: liamSchmidt.ID,
Subject: "Permission for Swimming Trip",
Text: "Dear Mr. and Mrs. Schmidt,\n\nWe are planning a swimming trip to the local pool next week and need your written permission for Liam to participate.\n\nPlease confirm:\n1. Liam has permission to attend\n2. Liam can swim (or will stay in the shallow area)\n3. Any water-related concerns we should know about\n\nThe trip will be supervised by 3 staff members with lifeguard certification.\n\nThank you for your prompt response!\n\nBest regards,\nBirgit",
InteractionType: "answer_required",
Deadline: &deadline2,
Draft: false,
PublishedAt: &publishedAt2,
}
var existingMsg2 model.Message
model.DB.Where("child_id = ? AND subject = ?", liamSchmidt.ID, "Permission for Swimming Trip").First(&existingMsg2)
if existingMsg2.ID == 0 {
model.DB.Create(&msg2)
model.DB.Model(&msg2).Association("Recipients").Append(&liamParent)
fmt.Println("Created message 'Permission for Swimming Trip'")
// Mark as read and answered
readAt := now.AddDate(0, 0, -13)
answeredAt := now.AddDate(0, 0, -12)
answer := "Yes, Liam has our permission to attend. He can swim well and has no water-related concerns. Thank you!"
model.DB.Create(&model.MessageRead{
MessageId: msg2.ID,
UserId: liamParent.ID,
ReadAt: &readAt,
Answer: &answer,
AnsweredAt: &answeredAt,
})
}
// Message 3: About Mason Schmidt - answer_possible, published 10 days ago
publishedAt3 := now.AddDate(0, 0, -10)
msg3 := model.Message{
CreatedById: teacherButterflies.ID,
ChildId: masonSchmidt.ID,
Subject: "Mason's Social Development Update",
Text: "Dear parents,\n\nI wanted to give you an update on Mason's social development. He has made wonderful friends in the Butterflies group and is always eager to include others in play activities.\n\nWe've noticed he's particularly good at sharing toys and taking turns. These are important skills that will serve him well!\n\nIf you have any questions or would like to discuss Mason's progress further, please feel free to reply.\n\nWarm regards,\nSabine",
InteractionType: "answer_possible",
Draft: false,
PublishedAt: &publishedAt3,
}
var existingMsg3 model.Message
model.DB.Where("child_id = ? AND subject = ?", masonSchmidt.ID, "Mason's Social Development Update").First(&existingMsg3)
if existingMsg3.ID == 0 {
model.DB.Create(&msg3)
model.DB.Model(&msg3).Association("Recipients").Append(&liamParent)
fmt.Println("Created message 'Mason's Social Development Update'")
// Mark as read but no answer
readAt := now.AddDate(0, 0, -8)
model.DB.Create(&model.MessageRead{
MessageId: msg3.ID,
UserId: liamParent.ID,
ReadAt: &readAt,
})
}
// Message 4: About Ella Müller (at Beta location) - informal, published 5 days ago
publishedAt4 := now.AddDate(0, 0, -5)
msg4 := model.Message{
CreatedById: teacherDolphins.ID,
ChildId: ellaMueller.ID,
Subject: "Ella's First Week in Dolphins Group",
Text: "Dear Mrs. Müller,\n\nWe wanted to let you know that Ella has settled in wonderfully with the Dolphins group! She's made friends quickly and enjoys our morning circle time activities.\n\nHer favorite activity so far has been our music and movement sessions. She has great rhythm!\n\nWe're very happy to have her with us.\n\nBest regards,\nDoris",
InteractionType: "informal",
Draft: false,
PublishedAt: &publishedAt4,
}
var existingMsg4 model.Message
model.DB.Where("child_id = ? AND subject = ?", ellaMueller.ID, "Ella's First Week in Dolphins Group").First(&existingMsg4)
if existingMsg4.ID == 0 {
model.DB.Create(&msg4)
model.DB.Model(&msg4).Association("Recipients").Append(&emmaParent)
fmt.Println("Created message 'Ella's First Week in Dolphins Group'")
// Unread message
}
// Message 5: About Lucas Hoffmann - answer_required, published 3 days ago, deadline upcoming
publishedAt5 := now.AddDate(0, 0, -3)
deadline5 := now.AddDate(0, 0, 4)
msg5 := model.Message{
CreatedById: teacherBees.ID,
ChildId: lucasHoffmann.ID,
Subject: "Dietary Information Update Required",
Text: "Dear Mr. Hoffmann,\n\nWe are updating our records and need to confirm Lucas's dietary information for the new menu planning.\n\nPlease confirm:\n1. Any food allergies or intolerances\n2. Foods Lucas should avoid\n3. Any special dietary requirements (vegetarian, religious, etc.)\n\nThis information is essential for our kitchen staff to ensure Lucas receives appropriate meals.\n\nThank you for your cooperation!\n\nBest regards,\nBirgit",
InteractionType: "answer_required",
Deadline: &deadline5,
Draft: false,
PublishedAt: &publishedAt5,
}
var existingMsg5 model.Message
model.DB.Where("child_id = ? AND subject = ?", lucasHoffmann.ID, "Dietary Information Update Required").First(&existingMsg5)
if existingMsg5.ID == 0 {
model.DB.Create(&msg5)
model.DB.Model(&msg5).Association("Recipients").Append(&lucasParent)
fmt.Println("Created message 'Dietary Information Update Required'")
// Unread message - deadline upcoming
}
// Message 6: About Emma Müller - informal, published 1 week ago
publishedAt6 := now.AddDate(0, 0, -7)
msg6 := model.Message{
CreatedById: teacherBees.ID,
ChildId: emmaMueller.ID,
Subject: "Reminder: Bring Rain Boots Tomorrow",
Text: "Dear parents,\n\nJust a quick reminder that we have planned outdoor activities for tomorrow, and the weather forecast shows possible rain.\n\nPlease ensure Emma brings:\n- Rain boots\n- A waterproof jacket\n- Extra socks (just in case)\n\nWe'll have lots of fun splashing in puddles!\n\nBest regards,\nBirgit",
InteractionType: "informal",
Draft: false,
PublishedAt: &publishedAt6,
}
var existingMsg6 model.Message
model.DB.Where("child_id = ? AND subject = ?", emmaMueller.ID, "Reminder: Bring Rain Boots Tomorrow").First(&existingMsg6)
if existingMsg6.ID == 0 {
model.DB.Create(&msg6)
model.DB.Model(&msg6).Association("Recipients").Append(&emmaParent)
fmt.Println("Created message 'Reminder: Bring Rain Boots Tomorrow'")
// Mark as read
readAt := now.AddDate(0, 0, -6)
model.DB.Create(&model.MessageRead{
MessageId: msg6.ID,
UserId: emmaParent.ID,
ReadAt: &readAt,
})
}
// Message 7: Batch message from Birgit to 3 Bees children (Emma, Liam, Lucas)
// Demonstrates the batch grouping feature - one send to multiple children
batchSubject := "Outdoor Week - Please Pack Appropriate Clothes"
var existingBatchMsg model.Message
model.DB.Where("subject = ? AND batch_id IS NOT NULL", batchSubject).First(&existingBatchMsg)
if existingBatchMsg.ID == 0 {
publishedAt7 := now.AddDate(0, 0, -2)
deadline7 := now.AddDate(0, 0, 5)
batchID := uuid.New().String()
batchChildren := []struct {
child model.Child
parent model.User
}{
{emmaMueller, emmaParent},
{liamSchmidt, liamParent},
{lucasHoffmann, lucasParent},
}
for _, bc := range batchChildren {
msg := model.Message{
CreatedById: teacherBees.ID,
ChildId: bc.child.ID,
Subject: batchSubject,
Text: "Dear parents,\n\nNext week is our annual Outdoor Week! We will be spending most of our time outside, rain or shine.\n\nPlease make sure your child brings:\n- Sturdy outdoor shoes or rain boots\n- Weather-appropriate clothing (layers recommended)\n- A change of clothes in a labeled bag\n- Sunscreen and a sun hat\n- A refillable water bottle\n\nWe have exciting activities planned including nature walks, garden planting, and a mini sports day on Friday.\n\nLooking forward to a great week!\n\nBest regards,\nBirgit",
InteractionType: "answer_possible",
Deadline: &deadline7,
Draft: false,
PublishedAt: &publishedAt7,
BatchID: &batchID,
}
model.DB.Create(&msg)
model.DB.Model(&msg).Association("Recipients").Append(&bc.parent)
}
// Mark Emma's parent as having read it
var batchMsg1 model.Message
model.DB.Where("batch_id = ? AND child_id = ?", batchID, emmaMueller.ID).First(&batchMsg1)
if batchMsg1.ID > 0 {
readAt := now.AddDate(0, 0, -1)
model.DB.Create(&model.MessageRead{
MessageId: batchMsg1.ID,
UserId: emmaParent.ID,
ReadAt: &readAt,
})
}
fmt.Println("Created batch message 'Outdoor Week' for Emma, Liam, and Lucas")
}
fmt.Println("Test messages creation complete.")
}
// CreateCalendarEvents creates test calendar events
func CreateCalendarEvents() {
fmt.Println("Creating test calendar events...")
// Get locations and groups
var locationAlpha model.Location
model.DB.Where("name = ?", "Alpha").First(&locationAlpha)
var locationBeta model.Location
model.DB.Where("name = ?", "Beta").First(&locationBeta)
var beesGroup model.Group
model.DB.Where("name = ?", "The Bees").First(&beesGroup)
var butterfliesGroup model.Group
model.DB.Where("name = ?", "The Butterflies").First(&butterfliesGroup)
var dolphinsGroup model.Group
model.DB.Where("name = ?", "The Dolphins").First(&dolphinsGroup)
// Get teacher for CreatedById
var teacherAlpha model.User
model.DB.Where("email = ?", "teacher.alpha@wippidu.app").First(&teacherAlpha)
var teacherBeta model.User
model.DB.Where("email = ?", "teacher.beta@wippidu.app").First(&teacherBeta)
var teacherBees model.User
model.DB.Where("email = ?", "teacher.bees@wippidu.app").First(&teacherBees)
var admin model.User
model.DB.Where("email = ?", "admin@wippidu.app").First(&admin)
now := time.Now()
// Event 1: Global holiday closure - 2 weeks from now
event1Start := now.AddDate(0, 0, 14).Truncate(24 * time.Hour)
event1End := now.AddDate(0, 0, 16).Truncate(24 * time.Hour)
event1 := model.CalendarEvent{
Title: "Easter Holiday Closure",
Description: "All facilities will be closed for the Easter holiday period. Regular operations resume on the following Monday.",
EventType: model.EventTypeHoliday,
StartDate: event1Start,
EndDate: event1End,
AllDay: true,
LocationId: nil, // Global
GroupId: nil,
CreatedById: admin.ID,
}
var existingEvent1 model.CalendarEvent
model.DB.Where("title = ?", "Easter Holiday Closure").First(&existingEvent1)
if existingEvent1.ID == 0 {
model.DB.Create(&event1)
fmt.Println("Created calendar event 'Easter Holiday Closure'")
}
// Event 2: Alpha location parent-teacher meeting - 3 weeks from now
event2Start := now.AddDate(0, 0, 21).Truncate(24*time.Hour).Add(18 * time.Hour) // 6 PM
event2End := now.AddDate(0, 0, 21).Truncate(24*time.Hour).Add(20 * time.Hour) // 8 PM
event2 := model.CalendarEvent{
Title: "Parent-Teacher Evening",
Description: "Annual parent-teacher evening for all Alpha location families. Individual appointment slots available - please sign up at the reception desk.",
EventType: model.EventTypeMeeting,
StartDate: event2Start,
EndDate: event2End,
AllDay: false,
LocationId: &locationAlpha.ID,
GroupId: nil,
CreatedById: teacherAlpha.ID,
}
var existingEvent2 model.CalendarEvent
model.DB.Where("title = ? AND location_id = ?", "Parent-Teacher Evening", locationAlpha.ID).First(&existingEvent2)
if existingEvent2.ID == 0 {
model.DB.Create(&event2)
fmt.Println("Created calendar event 'Parent-Teacher Evening' for Alpha")
}
// Event 3: The Bees group - Farm field trip - 1 week from now
event3Start := now.AddDate(0, 0, 7).Truncate(24*time.Hour).Add(9 * time.Hour) // 9 AM
event3End := now.AddDate(0, 0, 7).Truncate(24*time.Hour).Add(14 * time.Hour) // 2 PM
event3 := model.CalendarEvent{
Title: "Farm Field Trip",
Description: "The Bees group will visit Sunny Meadow Farm. Children will see farm animals, learn about crops, and enjoy a picnic lunch. Please pack appropriate shoes and clothes. Permission slips required.",
EventType: model.EventTypeActivity,
StartDate: event3Start,
EndDate: event3End,
AllDay: false,
LocationId: &locationAlpha.ID,
GroupId: &beesGroup.ID,
CreatedById: teacherBees.ID,
}
var existingEvent3 model.CalendarEvent
model.DB.Where("title = ? AND group_id = ?", "Farm Field Trip", beesGroup.ID).First(&existingEvent3)
if existingEvent3.ID == 0 {
model.DB.Create(&event3)
fmt.Println("Created calendar event 'Farm Field Trip' for The Bees")
}
// Event 4: Global staff training day - 1 month from now
event4Start := now.AddDate(0, 1, 0).Truncate(24 * time.Hour)
event4End := event4Start
event4 := model.CalendarEvent{
Title: "Staff Training Day",
Description: "Annual staff training day. All facilities closed. Children should be picked up by 1 PM the day before.",
EventType: model.EventTypeClosure,
StartDate: event4Start,
EndDate: event4End,
AllDay: true,
LocationId: nil, // Global
GroupId: nil,
CreatedById: admin.ID,
}
var existingEvent4 model.CalendarEvent
model.DB.Where("title = ?", "Staff Training Day").First(&existingEvent4)
if existingEvent4.ID == 0 {
model.DB.Create(&event4)
fmt.Println("Created calendar event 'Staff Training Day'")
}
// Event 5: Butterflies group - Art exhibition - 10 days from now
event5Start := now.AddDate(0, 0, 10).Truncate(24*time.Hour).Add(16 * time.Hour) // 4 PM
event5End := now.AddDate(0, 0, 10).Truncate(24*time.Hour).Add(18 * time.Hour) // 6 PM
event5 := model.CalendarEvent{
Title: "Children's Art Exhibition",
Description: "Come see the wonderful artwork created by the Butterflies group! Light refreshments will be served. All families welcome.",
EventType: model.EventTypeActivity,
StartDate: event5Start,
EndDate: event5End,
AllDay: false,
LocationId: &locationAlpha.ID,
GroupId: &butterfliesGroup.ID,
CreatedById: teacherAlpha.ID,
}
var existingEvent5 model.CalendarEvent
model.DB.Where("title = ? AND group_id = ?", "Children's Art Exhibition", butterfliesGroup.ID).First(&existingEvent5)
if existingEvent5.ID == 0 {
model.DB.Create(&event5)
fmt.Println("Created calendar event 'Children's Art Exhibition' for Butterflies")
}
// Event 6: Beta location summer festival - 6 weeks from now
event6Start := now.AddDate(0, 0, 42).Truncate(24*time.Hour).Add(10 * time.Hour) // 10 AM
event6End := now.AddDate(0, 0, 42).Truncate(24*time.Hour).Add(15 * time.Hour) // 3 PM
event6 := model.CalendarEvent{
Title: "Summer Festival",
Description: "Annual summer festival at Beta location! Games, food, music, and fun for the whole family. All Beta location families invited.",
EventType: model.EventTypeActivity,
StartDate: event6Start,
EndDate: event6End,
AllDay: false,
LocationId: &locationBeta.ID,
GroupId: nil,
CreatedById: teacherBeta.ID,
}
var existingEvent6 model.CalendarEvent
model.DB.Where("title = ? AND location_id = ?", "Summer Festival", locationBeta.ID).First(&existingEvent6)
if existingEvent6.ID == 0 {
model.DB.Create(&event6)
fmt.Println("Created calendar event 'Summer Festival' for Beta")
}
// Event 7: Dolphins group - Swimming day - 5 days from now
event7Start := now.AddDate(0, 0, 5).Truncate(24*time.Hour).Add(10 * time.Hour) // 10 AM
event7End := now.AddDate(0, 0, 5).Truncate(24*time.Hour).Add(12 * time.Hour) // 12 PM
event7 := model.CalendarEvent{
Title: "Swimming Day",
Description: "The Dolphins group will visit the local swimming pool. Please pack swimming gear and a towel. Permission forms must be signed.",
EventType: model.EventTypeActivity,
StartDate: event7Start,
EndDate: event7End,
AllDay: false,
LocationId: &locationBeta.ID,
GroupId: &dolphinsGroup.ID,
CreatedById: teacherBeta.ID,
}
var existingEvent7 model.CalendarEvent
model.DB.Where("title = ? AND group_id = ?", "Swimming Day", dolphinsGroup.ID).First(&existingEvent7)
if existingEvent7.ID == 0 {
model.DB.Create(&event7)
fmt.Println("Created calendar event 'Swimming Day' for Dolphins")
}
// Event 8: Global - Christmas closure - 2.5 months from now
event8Start := now.AddDate(0, 2, 15).Truncate(24 * time.Hour)
event8End := now.AddDate(0, 2, 20).Truncate(24 * time.Hour)
event8 := model.CalendarEvent{
Title: "Christmas Holiday Closure",
Description: "All facilities closed for the Christmas and New Year holiday period. We wish all families a wonderful holiday season!",
EventType: model.EventTypeHoliday,
StartDate: event8Start,
EndDate: event8End,
AllDay: true,
LocationId: nil, // Global
GroupId: nil,
CreatedById: admin.ID,
}
var existingEvent8 model.CalendarEvent
model.DB.Where("title = ?", "Christmas Holiday Closure").First(&existingEvent8)
if existingEvent8.ID == 0 {
model.DB.Create(&event8)
fmt.Println("Created calendar event 'Christmas Holiday Closure'")
}
// Event 9: Alpha location - Fire drill - tomorrow
event9Start := now.AddDate(0, 0, 1).Truncate(24*time.Hour).Add(11 * time.Hour) // 11 AM
event9End := now.AddDate(0, 0, 1).Truncate(24*time.Hour).Add(11*time.Hour + 30*time.Minute)
event9 := model.CalendarEvent{
Title: "Scheduled Fire Drill",
Description: "Monthly fire drill practice. All children and staff will participate in evacuation procedures.",
EventType: model.EventTypeOther,
StartDate: event9Start,
EndDate: event9End,
AllDay: false,
LocationId: &locationAlpha.ID,
GroupId: nil,
CreatedById: teacherAlpha.ID,
}
var existingEvent9 model.CalendarEvent
model.DB.Where("title = ? AND location_id = ?", "Scheduled Fire Drill", locationAlpha.ID).First(&existingEvent9)
if existingEvent9.ID == 0 {
model.DB.Create(&event9)
fmt.Println("Created calendar event 'Scheduled Fire Drill' for Alpha")
}
// Event 10: Global - Pediatrician visit - 4 weeks from now
event10Start := now.AddDate(0, 0, 28).Truncate(24*time.Hour).Add(9 * time.Hour)
event10End := now.AddDate(0, 0, 28).Truncate(24*time.Hour).Add(12 * time.Hour)
event10 := model.CalendarEvent{
Title: "Pediatrician Health Check",
Description: "Annual health check by visiting pediatrician Dr. Schmidt. Optional for all children - please inform us if you wish your child to participate.",
EventType: model.EventTypeOther,
StartDate: event10Start,
EndDate: event10End,
AllDay: false,
LocationId: nil, // All locations
GroupId: nil,
CreatedById: admin.ID,
}
var existingEvent10 model.CalendarEvent
model.DB.Where("title = ?", "Pediatrician Health Check").First(&existingEvent10)
if existingEvent10.ID == 0 {
model.DB.Create(&event10)
fmt.Println("Created calendar event 'Pediatrician Health Check'")
}
fmt.Println("Test calendar events creation complete.")
}
// CreateAbsenceNotifications creates test absence notifications for various children
// with dates relative to the current date to test immediate/active/future/archive tabs
func CreateAbsenceNotifications() {
fmt.Println("Creating test absence notifications...")
now := time.Now()
today := now.Truncate(24 * time.Hour)
// Get some children
var emmaMueller model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Emma", "Müller").First(&emmaMueller)
var liamSchmidt model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Liam", "Schmidt").First(&liamSchmidt)
var masonSchmidt model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Mason", "Schmidt").First(&masonSchmidt)
var lucasHoffmann model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Lucas", "Hoffmann").First(&lucasHoffmann)
var ellaMueller model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Ella", "Müller").First(&ellaMueller)
var noahFischer model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Noah", "Fischer").First(&noahFischer)
var oliviaWagner model.Child
model.DB.Where("first_name = ? AND last_name = ?", "Olivia", "Wagner").First(&oliviaWagner)
// Get parents
var emmaParent model.User
model.DB.Where("email = ?", "emma.mueller@wippidu.app").First(&emmaParent)
var liamParent model.User
model.DB.Where("email = ?", "liam.schmidt@wippidu.app").First(&liamParent)
var lucasParent model.User
model.DB.Where("email = ?", "lucas.hoffmann@wippidu.app").First(&lucasParent)
var noahParent model.User
model.DB.Where("email = ?", "noah.fischer@wippidu.app").First(&noahParent)
var oliviaParent model.User
model.DB.Where("email = ?", "olivia.wagner@wippidu.app").First(&oliviaParent)
// Get a teacher for acknowledged notifications
var teacherBees model.User
model.DB.Where("email = ?", "teacher.bees@wippidu.app").First(&teacherBees)
// === IMMEDIATE TAB: Today only absences ===
// Notification 1: Emma - Illness, today only (immediate, unacknowledged)
msg1 := "Emma has a slight fever this morning, keeping her home to rest."
absence1 := model.AbsenceNotification{
ChildId: emmaMueller.ID,
UserId: emmaParent.ID,
FromDate: today,
ToDate: today,
AbsenceType: "Illness",
Message: &msg1,
Acknowledged: false,
}
var existing1 model.AbsenceNotification
model.DB.Where("child_id = ? AND from_date = ? AND absence_type = ?", emmaMueller.ID, today, "Illness").First(&existing1)
if existing1.ID == 0 {
model.DB.Create(&absence1)
fmt.Println("Created absence notification for Emma (immediate, illness)")
}
// === ACTIVE TAB: Started in past, ends today or in future ===
// Notification 2: Liam - Vacation, started 3 days ago, ends in 2 days (active, acknowledged)
msg2 := "Family trip to visit grandparents."
absence2 := model.AbsenceNotification{
ChildId: liamSchmidt.ID,
UserId: liamParent.ID,
FromDate: today.AddDate(0, 0, -3),
ToDate: today.AddDate(0, 0, 2),
AbsenceType: "Vacation",
Message: &msg2,
Acknowledged: true,
AcknowledgedBy: &teacherBees.ID,
AcknowledgedAt: timePtr(now.AddDate(0, 0, -3)),
}
var existing2 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", liamSchmidt.ID, "Vacation", msg2).First(&existing2)
if existing2.ID == 0 {
model.DB.Create(&absence2)
fmt.Println("Created absence notification for Liam (active, vacation)")
}
// Notification 3: Mason - Illness, started yesterday, ends tomorrow (active, unacknowledged)
msg3 := "Mason has a stomach bug, doctor recommends rest for a few days."
absence3 := model.AbsenceNotification{
ChildId: masonSchmidt.ID,
UserId: liamParent.ID, // Same parent as Liam
FromDate: today.AddDate(0, 0, -1),
ToDate: today.AddDate(0, 0, 1),
AbsenceType: "Illness",
Message: &msg3,
Acknowledged: false,
}
var existing3 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", masonSchmidt.ID, "Illness", msg3).First(&existing3)
if existing3.ID == 0 {
model.DB.Create(&absence3)
fmt.Println("Created absence notification for Mason (active, illness)")
}
// === FUTURE TAB: Starts in the future ===
// Notification 4: Lucas - Vacation, starts in 5 days, lasts 1 week (future)
msg4 := "Summer holiday trip to the beach."
absence4 := model.AbsenceNotification{
ChildId: lucasHoffmann.ID,
UserId: lucasParent.ID,
FromDate: today.AddDate(0, 0, 5),
ToDate: today.AddDate(0, 0, 12),
AbsenceType: "Vacation",
Message: &msg4,
Acknowledged: false,
}
var existing4 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", lucasHoffmann.ID, "Vacation", msg4).First(&existing4)
if existing4.ID == 0 {
model.DB.Create(&absence4)
fmt.Println("Created absence notification for Lucas (future, vacation)")
}
// Notification 5: Ella - Other, starts in 2 weeks (future)
msg5 := "Medical appointment and recovery day."
absence5 := model.AbsenceNotification{
ChildId: ellaMueller.ID,
UserId: emmaParent.ID, // Same parent as Emma
FromDate: today.AddDate(0, 0, 14),
ToDate: today.AddDate(0, 0, 14),
AbsenceType: "Other",
Message: &msg5,
Acknowledged: false,
}
var existing5 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", ellaMueller.ID, "Other", msg5).First(&existing5)
if existing5.ID == 0 {
model.DB.Create(&absence5)
fmt.Println("Created absence notification for Ella (future, other)")
}
// === ARCHIVE TAB: Ended in the past ===
// Notification 6: Noah - Illness, 2 weeks ago for 3 days (archive, acknowledged)
msg6 := "Noah had a cold."
absence6 := model.AbsenceNotification{
ChildId: noahFischer.ID,
UserId: noahParent.ID,
FromDate: today.AddDate(0, 0, -14),
ToDate: today.AddDate(0, 0, -12),
AbsenceType: "Illness",
Message: &msg6,
Acknowledged: true,
AcknowledgedBy: &teacherBees.ID,
AcknowledgedAt: timePtr(now.AddDate(0, 0, -14)),
}
var existing6 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", noahFischer.ID, "Illness", msg6).First(&existing6)
if existing6.ID == 0 {
model.DB.Create(&absence6)
fmt.Println("Created absence notification for Noah (archive, illness)")
}
// Notification 7: Olivia - Vacation, 3 weeks ago for 5 days (archive, acknowledged)
msg7 := "Family reunion."
absence7 := model.AbsenceNotification{
ChildId: oliviaWagner.ID,
UserId: oliviaParent.ID,
FromDate: today.AddDate(0, 0, -21),
ToDate: today.AddDate(0, 0, -17),
AbsenceType: "Vacation",
Message: &msg7,
Acknowledged: true,
AcknowledgedBy: &teacherBees.ID,
AcknowledgedAt: timePtr(now.AddDate(0, 0, -21)),
}
var existing7 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", oliviaWagner.ID, "Vacation", msg7).First(&existing7)
if existing7.ID == 0 {
model.DB.Create(&absence7)
fmt.Println("Created absence notification for Olivia (archive, vacation)")
}
// Notification 8: Emma - past illness, 1 week ago (archive, acknowledged)
msg8 := "Emma had a minor ear infection."
absence8 := model.AbsenceNotification{
ChildId: emmaMueller.ID,
UserId: emmaParent.ID,
FromDate: today.AddDate(0, 0, -7),
ToDate: today.AddDate(0, 0, -5),
AbsenceType: "Illness",
Message: &msg8,
Acknowledged: true,
AcknowledgedBy: &teacherBees.ID,
AcknowledgedAt: timePtr(now.AddDate(0, 0, -7)),
}
var existing8 model.AbsenceNotification
model.DB.Where("child_id = ? AND absence_type = ? AND message = ?", emmaMueller.ID, "Illness", msg8).First(&existing8)
if existing8.ID == 0 {
model.DB.Create(&absence8)
fmt.Println("Created absence notification for Emma (archive, past illness)")
}
fmt.Println("Test absence notifications creation complete.")
}
// timePtr is a helper function to get a pointer to a time value
func timePtr(t time.Time) *time.Time {
return &t
}
package integrationtesting
import (
_ "embed"
"fmt"
"os"
"strings"
"time"
"wippidu_app_backend/internal/model"
)
//go:embed testdata_inserts_fixed.sql
var testdataSQL string
// TestingInitFromSQL loads test data from the embedded SQL dump.
// This is significantly faster than generating data at runtime (~15ms vs ~90s).
//
// IMPORTANT: The SQL dump is a cache of the slow mode output.
// When test data changes:
// 1. Update the Go code in testdata.go (slow mode functions)
// 2. Regenerate: APP_TESTING=true APP_TESTING_SLOW=true go run cmd/app-server/main.go
// 3. Dump: sqlite3 wippidu.db .dump | grep "^INSERT" | grep -v "sqlite_sequence\|INTO roles " > testdata_inserts_raw.sql
// 4. Fix unistr() calls (see Python script in commit history)
// 5. Replace testdata_inserts_fixed.sql
func TestingInitFromSQL() {
testing := os.Getenv("APP_TESTING")
if testing != "true" {
return
}
fmt.Println("=== Initializing Test Data (SQL Dump Mode) ===")
start := time.Now()
// Check if data already exists
var userCount int64
model.DB.Model(&model.User{}).Count(&userCount)
if userCount > 0 {
fmt.Println("Test data already exists, skipping initialization")
return
}
// Split SQL into individual statements and execute
statements := strings.Split(testdataSQL, ";\n")
tx := model.DB.Begin()
successCount := 0
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" || !strings.HasPrefix(stmt, "INSERT") {
continue
}
if err := tx.Exec(stmt).Error; err != nil {
tx.Rollback()
fmt.Printf("Error executing SQL: %v\nStatement: %s\n", err, stmt[:min(100, len(stmt))])
panic(err)
}
successCount++
}
if err := tx.Commit().Error; err != nil {
panic(err)
}
elapsed := time.Since(start)
fmt.Printf("=== Test Data Initialization Complete (%d statements, took %s) ===\n", successCount, elapsed)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Package logger provides structured logging with configurable log levels.
//
// Configuration via environment variables:
// - LOG_LEVEL: Set log level (debug, info, warn, error). Default: "info"
// - LOG_FORMAT: Set output format (text, json). Default: "text"
//
// Usage:
//
// logger.Debug("message", "key", value)
// logger.Info("message", "key", value)
// logger.Warn("message", "key", value)
// logger.Error("message", "key", value)
//
// With component prefix:
//
// log := logger.WithComponent("sync")
// log.Info("processing", "count", 10)
package logger
import (
"log/slog"
"os"
"strings"
)
var (
// defaultLogger is the package-level logger instance
defaultLogger *slog.Logger
// currentLevel stores the configured log level for runtime checks
currentLevel slog.Level
)
func init() {
// Initialize with defaults - can be reconfigured with Init()
Init()
}
// Init initializes the logger with configuration from environment variables.
// Call this early in main() after loading .env file.
func Init() {
level := getLogLevel()
currentLevel = level
opts := &slog.HandlerOptions{
Level: level,
}
var handler slog.Handler
format := strings.ToLower(os.Getenv("LOG_FORMAT"))
if format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
}
// getLogLevel returns the log level from LOG_LEVEL environment variable.
// Defaults to Info level if not set or invalid.
func getLogLevel() slog.Level {
levelStr := strings.ToLower(os.Getenv("LOG_LEVEL"))
switch levelStr {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// IsDebugEnabled returns true if debug logging is enabled.
// Useful for expensive debug operations that should be skipped in production.
func IsDebugEnabled() bool {
return currentLevel <= slog.LevelDebug
}
// WithComponent returns a logger with a component attribute for categorizing logs.
// Example: logger.WithComponent("sync").Info("started")
func WithComponent(component string) *slog.Logger {
return defaultLogger.With("component", component)
}
// Debug logs a message at debug level.
func Debug(msg string, args ...any) {
defaultLogger.Debug(msg, args...)
}
// Info logs a message at info level.
func Info(msg string, args ...any) {
defaultLogger.Info(msg, args...)
}
// Warn logs a message at warn level.
func Warn(msg string, args ...any) {
defaultLogger.Warn(msg, args...)
}
// Error logs a message at error level.
func Error(msg string, args ...any) {
defaultLogger.Error(msg, args...)
}
// Logger returns the underlying slog.Logger for advanced usage.
func Logger() *slog.Logger {
return defaultLogger
}
package middleware
import (
"net"
"net/http"
"strings"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// IntranetAPIAuth authenticates requests using bearer token + IP allowlist
// for the intranet data sync API endpoints.
//
// Authentication flow:
// 1. Extract Bearer token from Authorization header
// 2. Find matching active token by comparing hashes
// 3. Verify client IP is in the token's allowlist (if configured)
// 4. Update last_used_at and last_used_ip on success
// 5. Set "apiToken" in context for handlers to access
//
// Audit #464: previously used unstructured log.Printf and a single
// "[INTRANET-AUTH]" prefix on every per-request log line, which
// drowned the logs at any reasonable traffic level. Migrated to
// internal/logger with the routine "request received" / "token
// matched" / "success" lines demoted to Debug, and failure paths at
// Warn / Error with typed key/value attrs (clientIP, path, tokenID,
// tokenName) so log processors can filter by token or by failure
// type.
func IntranetAPIAuth() gin.HandlerFunc {
return func(c *gin.Context) {
clientIP := c.ClientIP()
requestPath := c.Request.URL.Path
requestMethod := c.Request.Method
logger.Debug("intranet auth: request",
"method", requestMethod,
"path", requestPath,
"clientIP", clientIP)
// Extract token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
logger.Warn("intranet auth: missing Authorization header",
"clientIP", clientIP,
"path", requestPath)
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "MISSING_AUTH_HEADER",
"message": "Authorization header is required",
},
})
c.Abort()
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
logger.Warn("intranet auth: non-Bearer scheme",
"clientIP", clientIP,
"path", requestPath)
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_AUTH_FORMAT",
"message": "Authorization header must use Bearer scheme",
},
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
token = strings.TrimSpace(token)
if token == "" {
logger.Warn("intranet auth: empty token after Bearer prefix",
"clientIP", clientIP,
"path", requestPath)
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "EMPTY_TOKEN",
"message": "Token is empty",
},
})
c.Abort()
return
}
// Mask token for logging (show first/last 4 chars)
maskedToken := maskToken(token)
logger.Debug("intranet auth: token received",
"clientIP", clientIP,
"path", requestPath,
"token", maskedToken)
// Find matching token by checking hash against all active tokens
var apiTokens []model.APIToken
if err := model.DB.Where("active = ?", true).Find(&apiTokens).Error; err != nil {
logger.Error("intranet auth: database error loading tokens",
"clientIP", clientIP,
"path", requestPath,
"error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": "DATABASE_ERROR",
"message": "Failed to validate token",
},
})
c.Abort()
return
}
logger.Debug("intranet auth: checking tokens",
"clientIP", clientIP,
"activeTokens", len(apiTokens))
var matchedToken *model.APIToken
for i := range apiTokens {
if util.CompareHashPassword(token, apiTokens[i].TokenHash) {
matchedToken = &apiTokens[i]
break
}
}
if matchedToken == nil {
logger.Warn("intranet auth: no matching token",
"clientIP", clientIP,
"path", requestPath,
"token", maskedToken)
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_TOKEN",
"message": "Invalid or expired token",
},
})
c.Abort()
return
}
// Verify IP allowlist
if !IsIPAllowed(clientIP, matchedToken.IPAllowlist) {
logger.Warn("intranet auth: IP not in allowlist",
"clientIP", clientIP,
"allowlist", matchedToken.IPAllowlist,
"tokenID", matchedToken.ID,
"tokenName", matchedToken.Name,
"path", requestPath)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "IP_NOT_ALLOWED",
"message": "Access denied from this IP address",
},
})
c.Abort()
return
}
logger.Debug("intranet auth: success",
"tokenID", matchedToken.ID,
"tokenName", matchedToken.Name,
"clientIP", clientIP,
"path", requestPath)
// Update last used timestamp and IP
now := time.Now()
if err := model.DB.Model(matchedToken).Updates(map[string]interface{}{
"last_used_at": now,
"last_used_ip": clientIP,
}).Error; err != nil {
logger.Warn("intranet auth: failed to update last_used fields",
"tokenID", matchedToken.ID,
"error", err)
}
// Set token in context for handlers
c.Set("apiToken", matchedToken)
c.Next()
}
}
// maskToken returns a masked version of the token for safe logging
// Shows first 4 and last 4 characters, or full token if too short
func maskToken(token string) string {
if len(token) <= 12 {
return "****"
}
return token[:4] + "..." + token[len(token)-4:]
}
// IsIPAllowed checks if clientIP is in the allowlist.
// The allowlist is a comma-separated string of IP addresses or CIDR ranges.
// An empty allowlist means all IPs are allowed.
//
// Supported formats:
// - Single IP: "192.168.1.1"
// - CIDR range: "192.168.1.0/24"
// - Mixed: "192.168.1.1, 10.0.0.0/8, 2001:db8::1"
// - IPv6: "2001:db8::1" or "2001:db8::/32"
func IsIPAllowed(clientIP, allowlist string) bool {
// Empty allowlist means all IPs are allowed
if strings.TrimSpace(allowlist) == "" {
return true
}
clientAddr := net.ParseIP(clientIP)
if clientAddr == nil {
// Invalid client IP - deny access
return false
}
allowed := strings.Split(allowlist, ",")
for _, entry := range allowed {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// Check if entry is a CIDR range
if strings.Contains(entry, "/") {
_, network, err := net.ParseCIDR(entry)
if err == nil && network.Contains(clientAddr) {
return true
}
} else {
// Direct IP comparison
entryIP := net.ParseIP(entry)
if entryIP != nil && entryIP.Equal(clientAddr) {
return true
}
}
}
return false
}
package middleware
import (
"strconv"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
func IsAuthorized() gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Cookie("token")
if err != nil {
logger.Debug("no auth cookie present")
return
}
claims, err := util.ParseToken(cookie)
if err != nil {
logger.Debug("auth token parse failed", "error", err)
return
}
//c.Set("role", claims.Role)
c.Set("userident", claims.UserIdent)
c.Set("useremail", claims.UserEmail)
if user, ok := model.UserFromUserIdent(claims.UserIdent); ok == nil {
model.DB.Preload("Roles").First(user, user.ID)
// Block deactivated users mid-session
if user.IsDeactivated() {
c.SetCookie("token", "", -1, "/", "", util.SecureCookies(), true)
logger.Debug("deactivated user blocked mid-session", "userId", user.ID, "email", user.Email)
return
}
user.SetLoggedIn()
// Check for impersonation (admin only)
if user.IsAdmin() {
impersonateCookie, err := c.Cookie("impersonate_user")
if err == nil && impersonateCookie != "" {
targetUserID, parseErr := strconv.ParseUint(impersonateCookie, 10, 32)
if parseErr == nil {
if targetUser, loadErr := model.UserFromUserIdent(uint(targetUserID)); loadErr == nil {
model.DB.Preload("Roles").First(targetUser, targetUser.ID)
// Security: Cannot impersonate another admin
if !targetUser.IsAdmin() {
c.Set("originalAdmin", user) // Preserve admin identity
c.Set("isImpersonating", true)
targetUser.SetLoggedIn()
user = targetUser // Swap user for rest of middleware
// Update userident to impersonated user so controllers that re-fetch work correctly
c.Set("userident", targetUser.ID)
}
}
}
}
}
c.Set("User", user)
// Calculate unread counts for navigation badges (for current/impersonated user)
unreadNews := service.GetUnreadNewsCount(model.DB, user.ID)
unreadMessages := service.GetUnreadMessagesCount(model.DB, user.ID)
unreadLetters := service.GetUnreadLettersCount(model.DB, user.ID)
c.Set("unreadNews", unreadNews)
c.Set("unreadMessages", unreadMessages)
c.Set("unreadLetters", unreadLetters)
// For employees: calculate pending actions (delegated letters, pending reviews)
// and unacknowledged absence notices
if user.IsEmployee() {
pendingActions := service.GetPendingEmployeeActionsCount(model.DB, user.ID)
c.Set("pendingEmployeeActions", pendingActions)
unacknowledgedAbsences := service.GetUnacknowledgedAbsenceNoticesCount(model.DB, user.ID)
c.Set("unacknowledgedAbsenceNotices", unacknowledgedAbsences)
unreadChatMessages := service.GetUnreadChatMessagesCount(model.DB, user.ID)
c.Set("unreadChatMessages", unreadChatMessages)
// Calculate allowLocationView for menu visibility
// Access depends on the location's EmployeeLocationAccess setting and user role
allowLocationView := false
// Check the employee's location setting
var groups []model.Group
model.DB.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", user.ID).
Preload("Location").
Limit(1).
Find(&groups)
if len(groups) > 0 && groups[0].Location != nil {
allowLocationView = groups[0].Location.CanEmployeeAccessAllChildren(user)
} else if user.IsHouseLeader() {
// LocationLeads always have access even if not in a group
allowLocationView = true
}
c.Set("allowLocationView", allowLocationView)
// Intranet daily group refresh check (for employees with ExternalID)
if service.NeedsIntranetRefresh(user) {
logger.Debug("triggering intranet group refresh",
"userId", user.ID,
"email", user.Email)
err := service.RefreshUserGroupMemberships(model.DB, user)
if err != nil {
logger.Warn("intranet refresh failed in middleware",
"userId", user.ID,
"email", user.Email,
"error", err)
// Reload user to get updated IntranetRefreshFailed status
model.DB.First(user, user.ID)
}
}
// Set intranet refresh status flags for templates
c.Set("intranetRefreshFailed", user.IntranetRefreshFailed)
c.Set("hasIntranetSync", user.ExternalID != nil && *user.ExternalID != "")
}
// For dual-role users (Employee+Parent), read active role preference from cookie
if user.IsParent() && user.IsEmployee() {
activeRoleCookie, err := c.Cookie("active_role")
if err == nil && activeRoleCookie != "" && user.HasRole(activeRoleCookie) {
c.Set("activeRole", activeRoleCookie)
} else {
// Default to Parent role for dual-role users
c.Set("activeRole", "Parent")
}
} else if len(user.Roles) > 0 {
// For single-role users, use their primary role
c.Set("activeRole", user.Roles[0].Name)
}
// For admin users, load locations and check for location filter cookie
if user.IsAdmin() {
var locations []model.Location
model.DB.Order("name").Find(&locations)
c.Set("allLocations", locations)
// Read admin location filter from cookie
adminLocationCookie, err := c.Cookie("admin_location")
if err == nil && adminLocationCookie != "" {
if locationID, err := strconv.Atoi(adminLocationCookie); err == nil {
c.Set("adminLocationId", locationID)
}
}
}
// Compute unified notification badges (single source of truth)
// This must be done after activeRole and adminLocationId are set
activeRole, _ := c.Get("activeRole")
activeRoleStr, _ := activeRole.(string)
adminLocationID := 0
if adminLocID, exists := c.Get("adminLocationId"); exists {
if id, ok := adminLocID.(int); ok {
adminLocationID = id
}
}
lang := c.GetString("lang")
if lang == "" {
lang = "de"
}
badgeService := service.NewNotificationBadgeService()
badges := badgeService.ComputeBadges(model.DB, user, activeRoleStr, lang, adminLocationID)
c.Set("badges", badges)
// For LocationLeads, check actual delegation access for menu visibility
// This checks both global settings AND per-location settings
isImpersonating := false
if _, exists := c.Get("isImpersonating"); exists {
isImpersonating = true
}
logger.Debug("Delegation menu check",
"userId", user.ID,
"email", user.Email,
"isHouseLeader", user.IsHouseLeader(),
"isAdmin", user.IsAdmin(),
"isImpersonating", isImpersonating)
if user.IsHouseLeader() && !user.IsAdmin() {
delegateChildren := user.CanAccessDelegatedAdmin(model.DB, model.DelegationChildren, nil)
delegateEnrollments := user.CanAccessDelegatedAdmin(model.DB, model.DelegationEnrollments, nil)
delegateGroups := user.CanAccessDelegatedAdmin(model.DB, model.DelegationGroups, nil)
delegateUsers := user.CanAccessDelegatedAdmin(model.DB, model.DelegationUsers, nil)
logger.Debug("LocationLead delegation result",
"userId", user.ID,
"email", user.Email,
"delegateChildren", delegateChildren,
"delegateEnrollments", delegateEnrollments,
"delegateGroups", delegateGroups,
"delegateUsers", delegateUsers)
c.Set("delegateChildren", delegateChildren)
c.Set("delegateEnrollments", delegateEnrollments)
c.Set("delegateGroups", delegateGroups)
c.Set("delegateUsers", delegateUsers)
}
logger.Debug("user authorized", "userId", user.ID, "email", user.Email)
} else {
// logout immediately, if the user does not exist
c.SetCookie("token", "", -1, "/", "", util.SecureCookies(), true)
}
c.Next()
}
}
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CookieDefaults sets the per-request SameSite policy that every
// subsequent c.SetCookie call inherits.
//
// gin stores SameSite on the Context (not the Engine), so the policy
// has to be applied at the top of the request rather than wired once
// during engine setup. Lax is the right default for a session-cookie
// app: top-level navigation (links from email, QR codes) keeps
// working, but cross-site POST cookies are blocked — which closes
// most of the CSRF surface for the cookie-based auth pattern.
//
// The Secure attribute is decided per-SetCookie via util.SecureCookies
// because gin's API does not expose a Context-level default for it.
func CookieDefaults() gin.HandlerFunc {
return func(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.Next()
}
}
package middleware
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/csrf"
)
// CSRF wires gorilla/csrf as a gin middleware (#464 audit).
//
// Token is checked from:
// - hidden form field "csrf_token"
// - "X-CSRF-Token" request header (for JS-driven POSTs)
//
// On GET/HEAD/OPTIONS/TRACE the middleware sets the CSRF cookie and
// makes the token available to templates via c.Get("csrfField"). On
// state-changing methods (POST/PUT/PATCH/DELETE) the token must match
// the cookie value or the request is rejected with 403.
//
// Failures return JSON for paths under /api/ and an HTML 403 page for
// everything else, so we don't break the JSON contract for the API
// consumers.
//
// CSRF is intentionally NOT applied to the token-authenticated
// /api/intranet/* surface (intranet sync) — those callers carry a
// bearer API token, not a session cookie, so the threat model that
// CSRF protects against does not apply.
func CSRF(secret []byte) gin.HandlerFunc {
useTLS := strings.EqualFold(os.Getenv("USE_TLS"), "true") ||
os.Getenv("USE_TLS") == "1" ||
strings.EqualFold(os.Getenv("USE_TLS"), "yes")
wrapped := csrf.Protect(
secret,
csrf.Path("/"),
csrf.Secure(useTLS),
csrf.HttpOnly(true),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.FieldName("csrf_token"),
csrf.RequestHeader("X-CSRF-Token"),
csrf.ErrorHandler(http.HandlerFunc(csrfErrorHandler)),
)
return func(c *gin.Context) {
if csrfExempt(c.Request.URL.Path) {
c.Next()
return
}
// When running without TLS termination at the app, gorilla/csrf
// must be told the request is plaintext HTTP. Otherwise it
// assumes HTTPS and rejects http:// Referer / Origin headers,
// which is the right behaviour for a TLS-terminated deploy but
// wrong for `USE_TLS=false` dev / pr / develop environments.
req := c.Request
if !useTLS {
req = csrf.PlaintextHTTPRequest(req)
}
// Run gorilla/csrf, then continue into the gin pipeline only
// if it didn't write a rejection response itself.
passed := false
wrapped(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.Request = r
c.Set("csrfField", csrf.TemplateField(r))
c.Set("csrfToken", csrf.Token(r))
passed = true
})).ServeHTTP(c.Writer, req)
if !passed {
c.Abort()
}
}
}
// csrfExempt reports whether a path should bypass CSRF.
//
// The exempt list is small and explicit:
// - /api/* — JSON surface; JS callers will be migrated to
// send X-CSRF-Token in a follow-up. Until then
// treating them as exempt prevents breaking the
// existing inline-JS flows. Intranet API is
// already in this prefix and uses token auth.
// - /login, /register, /activate/* — anonymous endpoints; an attacker
// forcing a victim's browser to POST here can
// only log the victim into the attacker's
// account, which is "login CSRF" — not in the
// threat model this PR is hardening.
func csrfExempt(path string) bool {
switch {
case strings.HasPrefix(path, "/api/"):
return true
case path == "/login", path == "/register":
return true
case strings.HasPrefix(path, "/activate/"):
return true
}
return false
}
// csrfErrorHandler is the response gorilla/csrf serves when a token is
// missing or invalid. We special-case JSON for /api/ paths so the
// dual-API contract (HTML+JSON) doesn't get broken by a CSRF failure.
func csrfErrorHandler(w http.ResponseWriter, r *http.Request) {
reason := csrf.FailureReason(r)
msg := "CSRF token verification failed"
if reason != nil {
msg = reason.Error()
}
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": map[string]string{
"code": "CSRF_FAILED",
"message": msg,
},
})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<!doctype html><meta charset="utf-8"><title>403 — Forbidden</title>
<body style="font-family:sans-serif;max-width:600px;margin:48px auto;padding:0 16px;color:#2c3e50">
<h1>Sicherheitsprüfung fehlgeschlagen</h1>
<p>Die Anfrage konnte nicht zugeordnet werden. Bitte lade die Seite neu und versuche es erneut.</p>
<p style="color:#7f8c8d;font-size:0.9rem">(` + msg + `)</p>
</body>`))
}
package middleware
import (
"net/http"
"strings"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// ForcePasswordChange middleware redirects users with ForcePasswordChange flag
// to the password change page, blocking access to all other authenticated routes.
// This middleware must run AFTER IsAuthorized() so the User is available in context.
func ForcePasswordChange() gin.HandlerFunc {
return func(c *gin.Context) {
// Get current user from context
userInterface, exists := c.Get("User")
if !exists {
c.Next()
return
}
user, ok := userInterface.(*model.User)
if !ok || user == nil || !user.IsLoggedIn() {
c.Next()
return
}
// Check if force password change is required
if !user.ForcePasswordChange {
c.Next()
return
}
// Get current path
path := c.Request.URL.Path
// Allow access to password change routes, logout, and static assets
allowedPaths := []string{
"/settings/password",
"/logout",
"/static/",
"/assets/",
}
for _, allowed := range allowedPaths {
if strings.Contains(path, allowed) {
c.Next()
return
}
}
// Get language from path or default to "de"
langStr := "de"
pathParts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(pathParts) > 0 && (pathParts[0] == "de" || pathParts[0] == "en") {
langStr = pathParts[0]
}
// Redirect to password change page
c.Redirect(http.StatusFound, "/"+langStr+"/settings/password")
c.Abort()
}
}
package middleware
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// RequireIntranetSync is a middleware that blocks access to child/group data routes
// if the employee's intranet refresh has failed. This ensures employees cannot access
// stale group memberships.
//
// This middleware should be applied to routes that serve child or group-related data
// for employees with intranet sync enabled.
func RequireIntranetSync() gin.HandlerFunc {
return func(c *gin.Context) {
// Get user from context
userInterface, ok := c.Get("User")
if !ok {
c.Next()
return
}
user, ok := userInterface.(*model.User)
if !ok || user == nil {
c.Next()
return
}
// Only check for employees with ExternalID (intranet-linked)
if user.ExternalID == nil || *user.ExternalID == "" {
c.Next()
return
}
if !user.IsEmployee() {
c.Next()
return
}
// Check if intranet refresh has failed
if user.IntranetRefreshFailed {
lang := c.GetString("language")
if lang == "" {
lang = "de"
}
// Block access and show refresh required page
util.RenderHTML(c, http.StatusForbidden, "intranet-refresh-required.html", gin.H{
"lang": lang,
"user": user,
"title": "Refresh Required",
})
c.Abort()
return
}
c.Next()
}
}
// IsIntranetRefreshRequired checks if the current user needs an intranet refresh
// This can be used in templates via context
func IsIntranetRefreshRequired(c *gin.Context) bool {
refreshFailed, exists := c.Get("intranetRefreshFailed")
if !exists {
return false
}
failed, ok := refreshFailed.(bool)
return ok && failed
}
package middleware
import (
"strings"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// LanguageDetection detects and sets the user's language preference
func LanguageDetection() gin.HandlerFunc {
return func(c *gin.Context) {
var lang string
// Priority 1: Extract language from URL path (e.g., /de/, /en/)
path := c.Request.URL.Path
if strings.HasPrefix(path, "/de/") || path == "/de" {
lang = "de"
c.Set("language", lang)
c.Next()
return
}
if strings.HasPrefix(path, "/en/") || path == "/en" {
lang = "en"
c.Set("language", lang)
c.Next()
return
}
if strings.HasPrefix(path, "/api/v1/de/") {
lang = "de"
c.Set("language", lang)
c.Next()
return
}
if strings.HasPrefix(path, "/api/v1/en/") {
lang = "en"
c.Set("language", lang)
c.Next()
return
}
// Priority 2: Get language from authenticated user's preference
if userInterface, exists := c.Get("User"); exists {
if user, ok := userInterface.(*model.User); ok && user.Language != "" {
if i18n.IsSupported(user.Language) {
lang = user.Language
}
}
}
// Priority 3: Get language from Accept-Language header
if lang == "" {
acceptLang := c.GetHeader("Accept-Language")
if acceptLang != "" {
// Parse Accept-Language header (e.g., "de-DE,de;q=0.9,en;q=0.8")
languages := parseAcceptLanguage(acceptLang)
for _, l := range languages {
if i18n.IsSupported(l) {
lang = l
break
}
}
}
}
// Priority 4: Default to English (for unsupported languages - triggers browser translation)
if lang == "" {
lang = "en"
}
c.Set("language", lang)
c.Next()
}
}
// parseAcceptLanguage parses the Accept-Language header and returns language codes in priority order
func parseAcceptLanguage(header string) []string {
var languages []string
// Split by comma
parts := strings.Split(header, ",")
for _, part := range parts {
// Remove quality value (e.g., ";q=0.9")
lang := strings.Split(strings.TrimSpace(part), ";")[0]
// Extract base language code (e.g., "de-DE" -> "de")
if strings.Contains(lang, "-") {
lang = strings.Split(lang, "-")[0]
}
// Add if not empty and not already in list
if lang != "" {
found := false
for _, existing := range languages {
if existing == lang {
found = true
break
}
}
if !found {
languages = append(languages, lang)
}
}
}
return languages
}
package middleware
import (
"fmt"
"net/http"
"wippidu_app_backend/internal/botprotection"
"github.com/gin-gonic/gin"
)
// RegistrationRateLimit returns a middleware that rate limits registration requests
func RegistrationRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
clientIP := c.ClientIP()
rateLimiter := botprotection.GetRateLimiter()
allowed, retryAfter := rateLimiter.ShouldAllow(clientIP)
if !allowed {
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
// Check if this is an API request or HTML request
if c.GetHeader("Accept") == "application/json" || c.GetHeader("Content-Type") == "application/json" {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "Too many registration attempts. Please try again later.",
"retry_after": int(retryAfter.Seconds()),
})
} else {
// For HTML requests, redirect with error
lang := c.DefaultQuery("lang", "de")
c.Redirect(http.StatusSeeOther, fmt.Sprintf("/register?lang=%s&error=rate_limited&retry_after=%d", lang, int(retryAfter.Seconds())))
c.Abort() // Must abort to prevent subsequent handlers from running
}
return
}
c.Next()
}
}
package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"wippidu_app_backend/internal/botprotection"
"github.com/gin-gonic/gin"
)
// Rate-limit middlewares for the auth-adjacent endpoints flagged by the
// security audit (#464): brute-force protection on login, password
// change and invitation-code lookup.
//
// Each endpoint gets its own RateLimiter instance so abuse on one path
// does not eat the quota on another. Limiters are constructed lazily
// with sync.Once so tests that don't import this file pay nothing.
var (
loginLimiterOnce sync.Once
loginLimiter *botprotection.RateLimiter
passwordLimiterOnce sync.Once
passwordLimiter *botprotection.RateLimiter
invitationLimiterOnce sync.Once
invitationLimiter *botprotection.RateLimiter
)
// loginRateLimiterConfig: tighter than the registration default. Login
// is per-account, so an attacker only needs ~one successful guess per
// account; 5 attempts / 15 min is the standard "brute force resistant"
// floor with the existing exponential backoff on top.
func loginRateLimiterConfig() botprotection.RateLimitConfig {
return botprotection.RateLimitConfig{
WindowDuration: 15 * time.Minute,
MaxAttempts: 5,
BlockDuration: 5 * time.Minute,
MaxBlockDuration: 24 * time.Hour,
}
}
// passwordChangeRateLimiterConfig: even tighter. A legitimate user
// rarely changes their password more than once a day; this caps a
// session-hijack from doing it 50 times in a row to lock out the
// real owner.
func passwordChangeRateLimiterConfig() botprotection.RateLimitConfig {
return botprotection.RateLimitConfig{
WindowDuration: 15 * time.Minute,
MaxAttempts: 5,
BlockDuration: 15 * time.Minute,
MaxBlockDuration: 24 * time.Hour,
}
}
// invitationLookupRateLimiterConfig: more permissive — the public
// invitation API is hit by the registration form during normal
// typing/validation. The goal here is just to slow down enumeration
// of invitation codes, not block normal use.
func invitationLookupRateLimiterConfig() botprotection.RateLimitConfig {
return botprotection.RateLimitConfig{
WindowDuration: 1 * time.Minute,
MaxAttempts: 30,
BlockDuration: 1 * time.Minute,
MaxBlockDuration: 1 * time.Hour,
}
}
// LoginRateLimit returns a middleware enforcing a per-IP limit on
// login attempts. On block: 429 + Retry-After for JSON, redirect to
// /login?error=rate_limited&retry_after=<seconds> for HTML.
func LoginRateLimit() gin.HandlerFunc {
loginLimiterOnce.Do(func() {
loginLimiter = botprotection.NewRateLimiter(loginRateLimiterConfig())
})
return rateLimitHandler(loginLimiter, "/login")
}
// PasswordChangeRateLimit limits password-change submissions per IP.
// On block: 429 + Retry-After for JSON, redirect back to the password
// page with an error param for HTML.
func PasswordChangeRateLimit() gin.HandlerFunc {
passwordLimiterOnce.Do(func() {
passwordLimiter = botprotection.NewRateLimiter(passwordChangeRateLimiterConfig())
})
return rateLimitHandler(passwordLimiter, "/{{lang}}/settings/password")
}
// InvitationLookupRateLimit limits public invitation-code lookups,
// so the /api/invitation/:code and /api/employee-invitation/:code
// endpoints can't be used to enumerate codes by brute force.
func InvitationLookupRateLimit() gin.HandlerFunc {
invitationLimiterOnce.Do(func() {
invitationLimiter = botprotection.NewRateLimiter(invitationLookupRateLimiterConfig())
})
return rateLimitHandler(invitationLimiter, "")
}
// rateLimitHandler builds the actual gin.HandlerFunc that checks the
// given limiter and emits the right response shape on a block.
//
// htmlRedirectTemplate is the URL to redirect HTML clients to on a
// block. The literal "{{lang}}" inside it is replaced with the
// detected request language (defaulting to "de"). Empty string means
// "always answer JSON, never redirect" — used for the API endpoints.
func rateLimitHandler(limiter *botprotection.RateLimiter, htmlRedirectTemplate string) gin.HandlerFunc {
return func(c *gin.Context) {
allowed, retryAfter := limiter.ShouldAllow(c.ClientIP())
if allowed {
c.Next()
return
}
secs := int(retryAfter.Seconds())
c.Header("Retry-After", fmt.Sprintf("%d", secs))
wantsJSON := htmlRedirectTemplate == "" ||
c.GetHeader("Accept") == "application/json" ||
strings.HasPrefix(c.GetHeader("Content-Type"), "application/json")
if wantsJSON {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many attempts, please try again later",
"retry_after": secs,
})
return
}
lang := "de"
if v, ok := c.Get("language"); ok {
if s, ok := v.(string); ok && s != "" {
lang = s
}
}
redirect := strings.ReplaceAll(htmlRedirectTemplate, "{{lang}}", lang)
sep := "?"
if strings.Contains(redirect, "?") {
sep = "&"
}
c.Redirect(http.StatusSeeOther,
fmt.Sprintf("%s%serror=rate_limited&retry_after=%d", redirect, sep, secs))
c.Abort()
}
}
package middleware
import (
"net/http"
"strings"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// RequireAdmin blocks access to any handler in the chain unless the
// authenticated user has the Admin role.
//
// The audit (#464) found 102 admin routes guarded only by
// `RequireApproval()`; each admin controller then hand-rolled its
// own `if !user.IsAdmin()` check (~150 sites). Forgetting one is
// privilege escalation. This middleware moves the policy to the
// router, where it can be visually verified for every admin route in
// one file.
//
// Must run AFTER `IsAuthorized()` so the User is in the context.
//
// Response shape mirrors the rest of the project: JSON 403 for paths
// under `/api/`, plain 403 status elsewhere (controllers that want
// to render an HTML 403 page can layer that on top — this middleware
// doesn't touch templates so it stays usable for JSON-only routes).
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user := util.MustUser(c)
if user != nil && user.IsAdmin() {
c.Next()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{
"code": "FORBIDDEN",
"message": "admin role required",
},
})
return
}
c.AbortWithStatus(http.StatusForbidden)
}
}
package middleware
import (
"net/http"
"strings"
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// RequireApproval middleware redirects unapproved users to the pending approval page,
// blocking access to all other authenticated routes.
// This middleware must run AFTER IsAuthorized() so the User is available in context.
// Unapproved users can still access: pending-approval page, logout, password settings, static assets.
func RequireApproval() gin.HandlerFunc {
return func(c *gin.Context) {
// Get current user from context
userInterface, exists := c.Get("User")
if !exists {
c.Next()
return
}
user, ok := userInterface.(*model.User)
if !ok || user == nil || !user.IsLoggedIn() {
c.Next()
return
}
// Check if user is approved
if user.IsApproved() {
c.Next()
return
}
// Get current path
path := c.Request.URL.Path
// Allow access to specific routes for unapproved users
allowedPaths := []string{
"/pending-approval",
"/logout",
"/settings/password",
"/static/",
"/assets/",
"/activate/", // Allow activation flow
}
for _, allowed := range allowedPaths {
if strings.Contains(path, allowed) {
c.Next()
return
}
}
// Get language from path or default to "de"
langStr := "de"
pathParts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(pathParts) > 0 && (pathParts[0] == "de" || pathParts[0] == "en") {
langStr = pathParts[0]
}
// Redirect to pending approval page
c.Redirect(http.StatusFound, "/"+langStr+"/pending-approval")
c.Abort()
}
}
package middleware
import (
"net/http"
"strings"
"wippidu_app_backend/internal/util"
"github.com/gin-gonic/gin"
)
// RequireAuth blocks any request that does not carry an authenticated
// user in the gin context. It is intended for router-level wiring so
// that authentication is visible in the route table, not implicit in
// the global middleware chain.
//
// The audit (#464) flagged the `/api/v1/*` JSON surface: those routes
// inherit authentication only from the global `IsAuthorized()` in
// `main.go`. Removing or reordering that single line would silently
// expose every JSON endpoint to anonymous requests, because
// `IsAuthorized()` is permissive — it sets `User` on success but does
// not abort on missing credentials. Wrapping the api group with this
// middleware makes the policy explicit at the router and fails closed.
//
// Must run AFTER `IsAuthorized()` so the User is in the context.
//
// Response shape mirrors `RequireAdmin`: JSON 401 envelope for paths
// under `/api/`, plain 401 status elsewhere.
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if util.MustUser(c) != nil {
c.Next()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "authentication required",
},
})
return
}
c.AbortWithStatus(http.StatusUnauthorized)
}
}
package middleware
import (
"testing"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/testhelpers"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// SetupTestDBWithModels creates a test database with all models migrated
func SetupTestDBWithModels(t *testing.T) *gorm.DB {
db := testhelpers.SetupTestDB(t)
// Auto-migrate all models
err := db.AutoMigrate(
&model.User{},
&model.News{},
&model.Child{},
&model.Group{},
&model.AbsenceNotification{},
&model.Role{},
&model.Location{},
&model.NewsRead{},
&model.Document{},
&model.BlackboardDocument{},
&model.Message{},
&model.MessageRead{},
&model.ParentalLetter{},
&model.ParentalLetterRead{},
&model.Passwd{},
&model.Pin{},
&model.LocationDevice{},
&model.UserChild{},
)
require.NoError(t, err)
// Set global DB
model.DB = db
// Populate basic roles
model.PopulateBasicData()
return db
}
// CreateTestUser creates a test user with the specified role
func CreateTestUser(t *testing.T, db *gorm.DB, email string, roleName string) *model.User {
user := &model.User{
Email: email,
Address: "Test Address 123",
Birthday: time.Now().AddDate(-30, 0, 0),
ActivatedAt: time.Now(),
ValidUntil: time.Now().AddDate(1, 0, 0),
Activated: true,
}
err := db.Create(user).Error
require.NoError(t, err)
// Assign role if specified
if roleName != "" {
role := &model.Role{}
err = db.Where("name = ?", roleName).First(role).Error
require.NoError(t, err)
err = db.Model(user).Association("Roles").Append(role)
require.NoError(t, err)
// Reload user with roles
db.Preload("Roles").First(user, user.ID)
}
return user
}
package model
import (
"os"
"testing"
"time"
"wippidu_app_backend/internal/logger"
"golang.org/x/crypto/bcrypt"
)
// InitAdminFromEnv creates an administrator account from environment variables.
// This is useful for initial deployment bootstrapping.
//
// Environment variables:
// - ADMIN_EMAIL: Email address for the admin account (required)
// - ADMIN_PASSWORD: Password for the admin account (required, min 10 characters)
// - ADMIN_FIRST_NAME: First name (optional, defaults to "Admin")
// - ADMIN_LAST_NAME: Last name (optional, defaults to "Administrator")
//
// If ADMIN_EMAIL or ADMIN_PASSWORD are not set, this function does nothing.
// If the admin user already exists, no changes are made.
func InitAdminFromEnv() {
email := os.Getenv("ADMIN_EMAIL")
password := os.Getenv("ADMIN_PASSWORD")
// Skip if required env vars are not set
if email == "" || password == "" {
return
}
// Validate password length
if len(password) < 10 {
logger.Warn("ADMIN_PASSWORD must be at least 10 characters, skipping admin initialization")
return
}
firstName := os.Getenv("ADMIN_FIRST_NAME")
if firstName == "" {
firstName = "Admin"
}
lastName := os.Getenv("ADMIN_LAST_NAME")
if lastName == "" {
lastName = "Administrator"
}
// Check if user already exists
var existingUser User
DB.Where("email = ?", email).First(&existingUser)
if existingUser.ID != 0 {
logger.Debug("admin user already exists, skipping initialization", "email", email)
return
}
// Create the admin user
now := time.Now()
adminUser := User{
Email: email,
FirstName: firstName,
LastName: lastName,
Activated: true,
ActivatedAt: now,
ForcePasswordChange: true, // Force password change on first login
}
if err := DB.Create(&adminUser).Error; err != nil {
logger.Error("failed to create admin user", "error", err)
return
}
// Create password hash. bcrypt cost 14 in production; MinCost (4)
// under `go test` so the testdata seed doesn't take minutes. Mirrors
// util.PasswordHashCost — this file is in the model package, which
// util imports, so the helper can't be called directly without a
// cycle.
cost := 14
if testing.Testing() {
cost = bcrypt.MinCost
}
passHashBytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
logger.Error("failed to hash admin password", "error", err)
return
}
passHash := string(passHashBytes)
passwd := Passwd{
UserId: adminUser.ID,
PassHash: passHash,
}
if err := DB.Create(&passwd).Error; err != nil {
logger.Error("failed to create admin password record", "error", err)
return
}
// Assign Admin role
var adminRole Role
DB.Where("name = ?", "Admin").First(&adminRole)
if adminRole.ID == 0 {
logger.Error("Admin role not found, ensure PopulateBasicData() was called first")
return
}
if err := DB.Model(&adminUser).Association("Roles").Append(&adminRole); err != nil {
logger.Error("failed to assign Admin role", "error", err)
return
}
logger.Info("created admin user from environment", "email", email, "name", firstName+" "+lastName)
}
package model
import (
"time"
"gorm.io/gorm"
)
// EventType represents the type of calendar event with associated color
type EventType string
const (
EventTypeActivity EventType = "activity" // Blue - regular activities
EventTypeClosure EventType = "closure" // Red - facility closures
EventTypeHoliday EventType = "holiday" // Pink - holidays
EventTypeMeeting EventType = "meeting" // Green - parent meetings
EventTypeOther EventType = "other" // Gray - miscellaneous
)
// CalendarEvent represents events displayed on the calendar.
// Can be scoped to global (all locations), location-wide, or group-specific.
//
// [impl->dsn~kalender-design~1]
type CalendarEvent struct {
gorm.Model
Title string `gorm:"size:255;not null"`
Description string `gorm:"type:text"`
EventType EventType `gorm:"size:50;default:'other'"`
StartDate time.Time `gorm:"not null;index"`
EndDate time.Time `gorm:"not null"`
AllDay bool `gorm:"default:true"`
// Scope fields (same pattern as News model)
LocationId *uint // Nullable for global events (nil = all locations)
Location *Location `gorm:"foreignKey:LocationId"`
GroupId *uint // Nullable for location-wide events
Group *Group `gorm:"foreignKey:GroupId"`
// Author tracking
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
// Visibility
EmployeeOnly bool `gorm:"default:false"` // If true, only visible to employees (not parents)
// Status
Cancelled bool `gorm:"default:false"`
CancelledAt *time.Time
CancelledBy *uint
// Import tracking
SystemGenerated bool `gorm:"default:false"`
// News integration
CreateNews bool `gorm:"default:false"`
SendReminder bool `gorm:"default:false"`
LinkedNewsId *uint
LinkedNews *News `gorm:"foreignKey:LinkedNewsId"`
ReminderNewsId *uint
ReminderNews *News `gorm:"foreignKey:ReminderNewsId"`
}
// EventTypeColor returns the CSS color variable for the event type
func (e *CalendarEvent) EventTypeColor() string {
switch e.EventType {
case EventTypeActivity:
return "var(--color-primary)" // Blue
case EventTypeClosure:
return "var(--color-error)" // Red
case EventTypeHoliday:
return "var(--color-holiday)" // Pink
case EventTypeMeeting:
return "var(--color-success)" // Green
default:
return "var(--color-text-secondary)" // Gray
}
}
// EventTypeI18nKey returns the i18n key for the event type
func (e *CalendarEvent) EventTypeI18nKey() string {
return "calendar.eventtype." + string(e.EventType)
}
// IsCancelled returns true if the event has been cancelled
func (e *CalendarEvent) IsCancelled() bool {
return e.Cancelled
}
// ScopeDisplay returns a description of the event scope for display
func (e *CalendarEvent) ScopeDisplay() string {
if e.GroupId != nil && e.Group != nil {
return e.Group.Name
}
if e.LocationId != nil && e.Location != nil {
return e.Location.Name
}
return "global"
}
// IsMultiDay returns true if the event spans multiple days
func (e *CalendarEvent) IsMultiDay() bool {
return e.StartDate.Format("2006-01-02") != e.EndDate.Format("2006-01-02")
}
// ScopeIcon returns a Unicode icon representing the event scope.
// Global events get a globe icon, location events get a house icon,
// and group events return empty string (abbreviation is used instead).
func (e *CalendarEvent) ScopeIcon() string {
if e.LocationId == nil && e.GroupId == nil {
return "\U0001F310" // 🌐
}
if e.GroupId == nil {
return "\U0001F3E0" // 🏠
}
return ""
}
// ScopeClass returns the CSS class name for the event scope
func (e *CalendarEvent) ScopeClass() string {
if e.LocationId == nil && e.GroupId == nil {
return "scope-global"
}
if e.GroupId == nil {
return "scope-location"
}
return "scope-group"
}
package model
// Database models for the Wippidu Kita App
//
// [impl->dsn~datenbank-design~1]
import (
"fmt"
"time"
"wippidu_app_backend/internal/logger"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// RelationshipRole represents the type of guardian relationship between a user and child
type RelationshipRole string
const (
RelationshipMother RelationshipRole = "mother"
RelationshipFather RelationshipRole = "father"
RelationshipStepmother RelationshipRole = "stepmother"
RelationshipStepfather RelationshipRole = "stepfather"
RelationshipGrandmother RelationshipRole = "grandmother"
RelationshipGrandfather RelationshipRole = "grandfather"
RelationshipGuardian RelationshipRole = "guardian" // Legal guardian
RelationshipFoster RelationshipRole = "foster" // Foster parent
RelationshipOther RelationshipRole = "other"
)
// EmployeeLocationAccess represents the level of employee access to all children at a location
type EmployeeLocationAccess string
const (
// EmployeeAccessLocationLeads means only LocationLeads can access all children (default)
EmployeeAccessLocationLeads EmployeeLocationAccess = "location_leads"
// EmployeeAccessGroupLeads means GroupLeads AND LocationLeads can access all children
EmployeeAccessGroupLeads EmployeeLocationAccess = "group_leads"
// EmployeeAccessAllEmployees means all employees can access all children
EmployeeAccessAllEmployees EmployeeLocationAccess = "all_employees"
)
// UserChild represents the many-to-many relationship between users and children
// with additional metadata for relationship role and contract validity period
//
// [impl->dsn~zugriffsmanagement-design~1]
type UserChild struct {
UserID uint `gorm:"primaryKey"`
ChildID uint `gorm:"primaryKey"`
User User `gorm:"foreignKey:UserID"`
Child Child `gorm:"foreignKey:ChildID"`
RelationshipRole RelationshipRole `gorm:"type:varchar(20);default:'other'"` // mother, father, guardian, etc.
ValidFrom *time.Time // Contract start date (NULL = always valid from past)
ValidUntil *time.Time // Contract end date (NULL = no expiry)
CreatedAt time.Time
UpdatedAt time.Time
}
// RelationshipDisplayKey returns the i18n key for displaying the relationship role
func (uc *UserChild) RelationshipDisplayKey() string {
return "relationship." + string(uc.RelationshipRole)
}
// Users and their children
type Child struct {
gorm.Model
ExternalID *string `gorm:"uniqueIndex;size:100"` // Intranet system ID
FirstName string
MiddleNames string
LastName string
Birthday time.Time
Users []User `gorm:"many2many:user_children;joinForeignKey:ChildID;joinReferences:UserID"`
Group *Group `gorm:"foreignKey:GroupId"`
GroupId *uint // Cached current group from active enrollment
Active bool
ValidFrom *time.Time // Cached from current enrollment (NULL = always valid from past)
ValidUntil *time.Time // Cached from current enrollment (NULL = no expiry)
AbsenceNotifications []AbsenceNotification `gorm:"foreignKey:ChildId"`
Messages []Message `gorm:"foreignKey:ChildId"`
Enrollments []Enrollment `gorm:"foreignKey:ChildID"`
EmployeeNotes string `gorm:"type:text"` // Staff-only notes (Markdown), not visible to parents
}
// Enrollment represents a child's assignment to a group for a specific time period.
// This is the source of truth for group assignments, synced from the intranet's Belegung table.
// The Child.GroupId field is a cached computed field derived from the current active enrollment.
//
// Status values:
// - 2 = Planning (future enrollment, not yet active)
// - 3 = Active (current contract)
type Enrollment struct {
gorm.Model
ChildID uint `gorm:"not null;index"`
Child *Child `gorm:"foreignKey:ChildID"`
GroupID uint `gorm:"not null;index"`
Group *Group `gorm:"foreignKey:GroupID"`
ValidFrom *time.Time `gorm:"type:date;index"` // Start date (NULL = always valid from past)
ValidUntil *time.Time `gorm:"type:date;index"` // End date (NULL = no expiry)
Status int `gorm:"default:3;index"` // 2=planning, 3=active
CareDaysBinary int `gorm:"column:care_days_binary"`
CareDaysCount int `gorm:"column:care_days_count"`
ExternalIDKrp *string `gorm:"size:50;index"` // External ID from krp system
ExternalIDUe3 *string `gorm:"size:50;index"` // External ID from ue3 system
Comments string `gorm:"type:text"`
SyncedAt time.Time
}
// IsBookedForWeekday checks if the enrollment covers a given weekday (time.Monday=1 .. time.Sunday=7).
// CareDaysBinary format: MSBit of LSByte is always 1 (bit 7), bits 6..0 = Mon..Sun.
// A value of 0 (including NULL from DB, which maps to Go zero value) means no restriction — child is booked every day.
func (e Enrollment) IsBookedForWeekday(weekday time.Weekday) bool {
if e.CareDaysBinary == 0 {
return true
}
// Bit positions: bit 6=Mon, bit 5=Tue, bit 4=Wed, bit 3=Thu, bit 2=Fri, bit 1=Sat, bit 0=Sun
var bitPos int
switch weekday {
case time.Monday:
bitPos = 6
case time.Tuesday:
bitPos = 5
case time.Wednesday:
bitPos = 4
case time.Thursday:
bitPos = 3
case time.Friday:
bitPos = 2
case time.Saturday:
bitPos = 1
case time.Sunday:
bitPos = 0
}
return (e.CareDaysBinary & (1 << bitPos)) != 0
}
func (c Child) GetGroup() string {
return c.Group.Name
}
// AbsenceNotification represents a parent-submitted notification about their child's absence
//
// [impl->dsn~abwesenheitsmeldungen-design~1]
type AbsenceNotification struct {
gorm.Model
ChildId uint
Child Child `gorm:"foreignKey:ChildId"`
UserId uint // Parent who submitted the notification
User User `gorm:"foreignKey:UserId"`
FromDate time.Time
ToDate time.Time
AbsenceType string // "Vacation", "Illness", "Other"
Message *string // Optional message from parent
Acknowledged bool // Has staff acknowledged this notification?
AcknowledgedBy *uint // Which staff member acknowledged
AcknowledgedAt *time.Time
}
// Messaging system
//
// [impl->dsn~elternbriefe-design~1]
type ParentalLetter struct {
gorm.Model
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
DelegatedToId *uint
DelegatedTo *User `gorm:"foreignKey:DelegatedToId"`
ReviewerId *uint
Reviewer *User `gorm:"foreignKey:ReviewerId"`
ReviewStatus string // "draft", "pending_review", "approved", "published"
ReviewComments *string
ReviewedAt *time.Time
LastEditedById *uint
LastEditedBy *User `gorm:"foreignKey:LastEditedById"`
EditedAt *time.Time
Subject string
Text string
InteractionType string // "informal", "answer_possible", "answer_required", "poll_single", "poll_multi", "table"
Deadline *time.Time
IsAnonymousPoll bool `gorm:"default:false"` // For anonymous poll voting
HidePollResults bool `gorm:"default:false"` // Hide poll results from parents after voting
AllowMultiVote bool `gorm:"default:false"` // Deprecated: use InteractionType poll_multi instead
LocationId uint
Location Location `gorm:"foreignKey:LocationId"`
GroupId *uint
Group *Group `gorm:"foreignKey:GroupId"`
Draft bool
PublishedAt *time.Time
ValidUntil *time.Time
AnswersLastViewedAt *time.Time
Attachments []Attachment `gorm:"foreignKey:ParentalLetterId"`
// Set when this letter was created by a stand-in acting on behalf of a lead.
CreatedAsStandInForID *uint `gorm:"index"`
CreatedAsStandInFor *User `gorm:"foreignKey:CreatedAsStandInForID"`
}
type ParentalLetterRead struct {
gorm.Model
// LetterId / UserId carry single-column indexes because the unread-
// letters badge runs an EXISTS subquery filtering on (letter_id,
// user_id, deleted_at IS NULL) for every page load. Without these
// the count is a full table scan once the table grows.
LetterId uint `gorm:"index"`
Letter ParentalLetter `gorm:"foreignKey:LetterId"`
UserId uint `gorm:"index"`
User User `gorm:"foreignKey:UserId"`
ReadAt *time.Time
NotifiedAt *time.Time
Answer *string
AnsweredAt *time.Time
}
// =============== POLL MODELS ===============
// PollOption - selectable option in a poll or survey question
type PollOption struct {
gorm.Model
LetterId *uint `gorm:"index"` // Legacy: for simple polls (nullable for survey questions)
Letter *ParentalLetter `gorm:"foreignKey:LetterId"`
QuestionId *uint `gorm:"index"` // For survey questions
Question *SurveyQuestion `gorm:"foreignKey:QuestionId"`
Text string `gorm:"not null"`
SortOrder int `gorm:"default:0"`
}
// PollVote - parent's vote on poll option(s) (legacy, kept for backward compatibility)
type PollVote struct {
gorm.Model
OptionId uint `gorm:"not null;index"`
Option PollOption `gorm:"foreignKey:OptionId"`
UserId uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserId"`
LetterId uint `gorm:"not null;index"` // Denormalized for efficiency
}
// =============== SURVEY MODELS ===============
// SurveyQuestion - a question in a multi-question survey (Befragung)
type SurveyQuestion struct {
gorm.Model
LetterId uint `gorm:"not null;index"`
Letter ParentalLetter `gorm:"foreignKey:LetterId"`
Text string `gorm:"not null"` // The question text
QuestionType string `gorm:"not null"` // "single_choice", "multi_choice", "free_text"
SortOrder int `gorm:"default:0"`
Options []PollOption `gorm:"foreignKey:QuestionId"` // Options for choice questions
}
// SurveyResponse - parent's response to a survey question
type SurveyResponse struct {
gorm.Model
QuestionId uint `gorm:"not null;index"`
Question SurveyQuestion `gorm:"foreignKey:QuestionId"`
OptionId *uint `gorm:"index"` // Nullable for free text questions
Option *PollOption `gorm:"foreignKey:OptionId"`
UserId uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserId"`
LetterId uint `gorm:"not null;index"` // Denormalized for efficiency
FreeTextAnswer string `gorm:"type:text"` // For free_text questions
}
// =============== TABLE MODELS ===============
// TableColumn - defines a column header for a fill-in table
type TableColumn struct {
gorm.Model
LetterId uint `gorm:"not null;index"`
Letter ParentalLetter `gorm:"foreignKey:LetterId"`
Name string `gorm:"not null;size:100"`
SortOrder int `gorm:"default:0"`
}
// TableRow - defines a row label for a fill-in table
// For predefined rows, UserId is nil. For user-created rows (in tables without predefined rows), UserId tracks who created it.
type TableRow struct {
gorm.Model
LetterId uint `gorm:"not null;index"`
Letter ParentalLetter `gorm:"foreignKey:LetterId"`
Label string `gorm:"not null;size:200"`
SortOrder int `gorm:"default:0"`
UserId *uint `gorm:"index"` // nil for predefined rows, set for user-created entries
User *User `gorm:"foreignKey:UserId"`
}
// TableCell - parent's filled-in value for a specific row/column
type TableCell struct {
gorm.Model
LetterId uint `gorm:"not null;index"`
Letter ParentalLetter `gorm:"foreignKey:LetterId"`
RowId uint `gorm:"not null;index"`
Row TableRow `gorm:"foreignKey:RowId"`
ColumnId uint `gorm:"not null;index"`
Column TableColumn `gorm:"foreignKey:ColumnId"`
UserId uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserId"`
Value string `gorm:"size:500"`
}
// Attachment represents a file attached to a parental letter or news entry
// Supports PDFs and images (PNG, JPG, GIF) stored as BLOBs
// ParentalLetterId/NewsId can be null for orphan attachments (uploaded via AJAX before entity is saved)
//
// [impl->dsn~elternbriefe-design~1]
type Attachment struct {
gorm.Model
ParentalLetterId *uint `gorm:"index"`
ParentalLetter ParentalLetter `gorm:"foreignKey:ParentalLetterId"`
NewsId *uint `gorm:"index"`
News News `gorm:"foreignKey:NewsId"`
UploadedById *uint `gorm:"index"`
UploadedBy *User `gorm:"foreignKey:UploadedById"`
Filename string `gorm:"size:255;not null"`
MimeType string `gorm:"size:100;not null"`
Size int64 `gorm:"not null"`
Content []byte `gorm:"type:bytea"`
}
// Message represents direct messages from employees to parents
//
// [impl->dsn~nachrichten-design~1]
type Message struct {
gorm.Model
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
ChildId uint
Child Child `gorm:"foreignKey:ChildId"`
Subject string
Text string
InteractionType string // "informal", "answer_possible", "answer_required"
Deadline *time.Time
Recipients []User `gorm:"many2many:message_recipients"`
Draft bool
PublishedAt *time.Time
ValidUntil *time.Time
EditedAt *time.Time
LastEditedById *uint
LastEditedBy *User `gorm:"foreignKey:LastEditedById"`
AnswersLastViewedAt *time.Time
BatchID *string `gorm:"index"`
}
type MessageRead struct {
gorm.Model
// MessageId / UserId carry indexes — the unread-messages badge
// hits this table via an EXISTS subquery on every page render.
MessageId uint `gorm:"index"`
Message Message `gorm:"foreignKey:MessageId"`
UserId uint `gorm:"index"`
User User `gorm:"foreignKey:UserId"`
ReadAt *time.Time
Answer *string
AnsweredAt *time.Time
}
type MessageTemplate struct {
gorm.Model
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
Name string
Subject string
Text string
InteractionType string // "informal", "answer_possible", "answer_required"
}
// Documents - File sharing for parents and staff
//
// [impl->dsn~dokumentenverwaltung-design~1]
type Document struct {
gorm.Model
Title string
Path string
PublishedAt time.Time
ValidUntil *time.Time
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
}
type BlackboardDocument struct {
gorm.Model
Title string
Path string
PublishedAt time.Time
ValidUntil *time.Time
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
}
// News - Announcements and updates for parents and staff
//
// [impl->dsn~abwesenheitsmeldungen-design~1]
type News struct {
gorm.Model
Title string
Text string
CreatedById uint
CreatedBy User `gorm:"foreignKey:CreatedById"`
PublishedAt time.Time
ValidUntil *time.Time
LocationId *uint // Nullable for global news (nil = all locations)
Location *Location `gorm:"foreignKey:LocationId"`
GroupId *uint // Nullable for location-wide news
Group *Group `gorm:"foreignKey:GroupId"`
EmployeeOnly bool `gorm:"default:false"` // If true, only visible to employees (not parents)
// Calendar event link (set when news is auto-generated from a calendar event)
CalendarEventId *uint `gorm:"index"`
CalendarEvent *CalendarEvent `gorm:"foreignKey:CalendarEventId"`
// Set when this news was created by a stand-in acting on behalf of a lead.
CreatedAsStandInForID *uint `gorm:"index"`
CreatedAsStandInFor *User `gorm:"foreignKey:CreatedAsStandInForID"`
}
type NewsRead struct {
gorm.Model
// NewsId / UserId carry indexes — the unread-news badge hits this
// table via an EXISTS subquery on every page render.
NewsId uint `gorm:"index"`
News News `gorm:"foreignKey:NewsId"`
UserId uint `gorm:"index"`
User User `gorm:"foreignKey:UserId"`
ReadAt *time.Time
}
// EmailSettings stores SMTP configuration for sending emails
// This is a singleton table - only one row should exist
type EmailSettings struct {
gorm.Model
SMTPHost string `gorm:"size:255"`
SMTPPort int `gorm:"default:587"`
SMTPUser string `gorm:"size:255"`
SMTPPassword string `gorm:"size:255"` // Should be encrypted at rest in production
SMTPFrom string `gorm:"size:255"` // e.g., "Wippidu <noreply@wippidu.app>"
SMTPUseTLS bool `gorm:"default:true"`
Enabled bool `gorm:"default:false"` // Master switch for email sending
}
// GetEmailSettings retrieves the singleton email settings record
// Returns nil if no settings exist yet
func GetEmailSettings() (*EmailSettings, error) {
var settings EmailSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveEmailSettings creates or updates the singleton email settings record
func SaveEmailSettings(settings *EmailSettings) error {
var existing EmailSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Create new settings
return DB.Create(settings).Error
}
// Update existing settings
settings.ID = existing.ID
return DB.Save(settings).Error
}
// LegalSettings stores legal page content (Impressum and Datenschutzerklärung)
// This is a singleton table - only one row should exist
// Content is stored separately for German (DE) and English (EN)
type LegalSettings struct {
gorm.Model
ImpressumDE string `gorm:"type:text"` // German Impressum content (Markdown)
ImpressumEN string `gorm:"type:text"` // English Impressum content (Markdown)
DatenschutzDE string `gorm:"type:text"` // German Privacy Policy content (Markdown)
DatenschutzEN string `gorm:"type:text"` // English Privacy Policy content (Markdown)
}
// DelegationSettings stores settings for delegating admin functions to LocationLeads
// This is a singleton table - only one row should exist
// When enabled, LocationLeads can perform admin functions scoped to their location
type DelegationSettings struct {
gorm.Model
DelegateUsers bool `gorm:"default:false"` // Allow LocationLeads to manage users at their location
DelegateChildren bool `gorm:"default:false"` // Allow LocationLeads to manage children at their location
DelegateGroups bool `gorm:"default:false"` // Allow LocationLeads to manage groups at their location
DelegateEnrollments bool `gorm:"default:false"` // Allow LocationLeads to manage enrollments at their location
}
// GetDelegationSettings retrieves the singleton delegation settings record
// Returns a default settings struct with all flags false if no settings exist yet
func GetDelegationSettings() (*DelegationSettings, error) {
var settings DelegationSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// Return default settings (all delegations disabled)
return &DelegationSettings{}, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveDelegationSettings creates or updates the singleton delegation settings record
func SaveDelegationSettings(settings *DelegationSettings) error {
var existing DelegationSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Create new settings
return DB.Create(settings).Error
}
// Update existing settings
settings.ID = existing.ID
return DB.Save(settings).Error
}
// SessionSettings stores session lifetime configuration
// This is a singleton table - only one row should exist
type SessionSettings struct {
gorm.Model
ShortSessionMinutes int `gorm:"default:60"` // Duration of "short login" sessions in minutes
}
// GetSessionSettings retrieves the singleton session settings record
// Returns default settings (60 min) if no settings exist yet
func GetSessionSettings() (*SessionSettings, error) {
var settings SessionSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return &SessionSettings{ShortSessionMinutes: 60}, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveSessionSettings creates or updates the singleton session settings record
func SaveSessionSettings(settings *SessionSettings) error {
var existing SessionSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return DB.Create(settings).Error
}
settings.ID = existing.ID
return DB.Save(settings).Error
}
// SyncSettings stores settings for automated sync processing
// This is a singleton table - only one row should exist
type SyncSettings struct {
gorm.Model
AutoSyncEnabled bool `gorm:"default:false"`
}
// GetSyncSettings retrieves the singleton sync settings record
// Returns a default settings struct with AutoSyncEnabled=false if no settings exist yet
func GetSyncSettings() (*SyncSettings, error) {
var settings SyncSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return &SyncSettings{AutoSyncEnabled: false}, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveSyncSettings creates or updates the singleton sync settings record
func SaveSyncSettings(settings *SyncSettings) error {
var existing SyncSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return DB.Create(settings).Error
}
settings.ID = existing.ID
return DB.Save(settings).Error
}
// GetLegalSettings retrieves the singleton legal settings record
// Returns nil if no settings exist yet
func GetLegalSettings() (*LegalSettings, error) {
var settings LegalSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveLegalSettings creates or updates the singleton legal settings record
func SaveLegalSettings(settings *LegalSettings) error {
var existing LegalSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Create new settings
return DB.Create(settings).Error
}
// Update existing settings
settings.ID = existing.ID
return DB.Save(settings).Error
}
// FAQSettings stores FAQ content for different user roles
// This is a singleton table - only one row should exist
// Content is stored separately for German (DE) and English (EN) and by role (Parent/Employee)
type FAQSettings struct {
gorm.Model
FAQParentDE string `gorm:"type:text"` // German FAQ content for parents (Markdown)
FAQParentEN string `gorm:"type:text"` // English FAQ content for parents (Markdown)
FAQEmployeeDE string `gorm:"type:text"` // German FAQ content for employees (Markdown)
FAQEmployeeEN string `gorm:"type:text"` // English FAQ content for employees (Markdown)
}
// GetFAQSettings retrieves the singleton FAQ settings record
// Returns nil if no settings exist yet
func GetFAQSettings() (*FAQSettings, error) {
var settings FAQSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveFAQSettings creates or updates the singleton FAQ settings record
func SaveFAQSettings(settings *FAQSettings) error {
var existing FAQSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Create new settings
return DB.Create(settings).Error
}
// Update existing settings
settings.ID = existing.ID
return DB.Save(settings).Error
}
// Wippidu internal organisation
type Location struct {
gorm.Model
ExternalID *string `gorm:"uniqueIndex;size:100"` // Intranet system ID
Name string
Address string
LeadId *uint
Lead *User `gorm:"foreignKey:LeadId"`
Lead2ndId *uint
Lead2nd *User `gorm:"foreignKey:Lead2ndId"`
EmployeeLocationAccess EmployeeLocationAccess `gorm:"type:varchar(20);default:location_leads"` // Employee access level for all children at location
Bundesland string `gorm:"size:5"` // German federal state code, e.g. "DE-BW"
// Per-location delegation settings (only effective if global delegation is enabled)
DelegateUsers bool `gorm:"default:true"` // Allow LocationLead to manage users (if globally enabled)
DelegateChildren bool `gorm:"default:true"` // Allow LocationLead to manage children (if globally enabled)
DelegateGroups bool `gorm:"default:true"` // Allow LocationLead to manage groups (if globally enabled)
DelegateEnrollments bool `gorm:"default:true"` // Allow LocationLead to manage enrollments (if globally enabled)
// Absence notification settings
AbsenceNotificationCutoff *string `gorm:"size:5"` // Time in "HH:MM" format, e.g., "09:00". NULL = no restriction
// Calendar & News defaults
DefaultCreateNewsForEvents bool `gorm:"default:true"`
DefaultEventReminder bool `gorm:"default:true"`
// Archive retention settings (days)
NewsRetentionDays int `gorm:"default:30"`
LetterRetentionDays int `gorm:"default:90"`
MessageRetentionDays int `gorm:"default:14"`
}
// CanEmployeeAccessAllChildren checks if a user can access all children at this location
// based on their role and the location's EmployeeLocationAccess setting.
func (l *Location) CanEmployeeAccessAllChildren(user *User) bool {
// LocationLeads always have access
if user.IsHouseLeader() {
return true
}
// GroupLeads have access if setting is group_leads or all_employees
if user.IsGroupLeader() {
return l.EmployeeLocationAccess == EmployeeAccessGroupLeads ||
l.EmployeeLocationAccess == EmployeeAccessAllEmployees
}
// Regular employees only have access if setting is all_employees
return l.EmployeeLocationAccess == EmployeeAccessAllEmployees
}
type LocationDevice struct {
gorm.Model
LocationId uint
Location *Location `gorm:"foreignKey:LocationId"`
DeviceIdent string
}
type Group struct {
gorm.Model
ExternalID *string `gorm:"uniqueIndex;size:100"` // Intranet system ID
Name string
LocationId uint
Location *Location `gorm:"foreignKey:LocationId"`
Lead *User `gorm:"foreignKey:LeadId"`
LeadId *uint
PhoneNumber string `gorm:"size:50"`
Teachers []User `gorm:"many2many:group_teachers"`
}
// ChildCluster represents a named subset of children within a group
// Used for quick selection when sending parent messages (e.g., "Vorschüler")
// A child can belong to multiple clusters
type ChildCluster struct {
gorm.Model
Name string `gorm:"size:100;not null"`
GroupId uint `gorm:"not null;index"`
Group *Group `gorm:"foreignKey:GroupId"`
CreatedById uint `gorm:"not null"`
CreatedBy *User `gorm:"foreignKey:CreatedById"`
Children []Child `gorm:"many2many:child_cluster_children;"`
}
// EmployeeChat represents a chat room for internal employee communication
// Each location has a default chat that includes all employees
// Location leads can create additional custom chats
type EmployeeChat struct {
gorm.Model
Name string `gorm:"size:200;not null"`
LocationId uint `gorm:"not null;index"`
Location *Location `gorm:"foreignKey:LocationId"`
CreatedById *uint // NULL for auto-created default chats
CreatedBy *User `gorm:"foreignKey:CreatedById"`
IsDefault bool `gorm:"default:false"`
Participants []User `gorm:"many2many:employee_chat_participants"`
}
// ChatMessage represents a single message in an employee chat
type ChatMessage struct {
gorm.Model
ChatId uint `gorm:"not null;index"`
Chat *EmployeeChat `gorm:"foreignKey:ChatId"`
SenderId uint `gorm:"not null;index"`
Sender *User `gorm:"foreignKey:SenderId"`
Text string `gorm:"type:text;not null"`
}
// ChatMessageRead tracks read status for unread badge calculation
type ChatMessageRead struct {
gorm.Model
MessageId uint `gorm:"not null;index"`
Message *ChatMessage `gorm:"foreignKey:MessageId"`
UserId uint `gorm:"not null;index"`
User *User `gorm:"foreignKey:UserId"`
ReadAt *time.Time
}
// ChangelogEntry represents a single changelog entry for a version
type ChangelogEntry struct {
gorm.Model
Version string `gorm:"size:50;not null;index"` // e.g., "1.2.3"
ReleaseDate time.Time `gorm:"not null"`
Title string `gorm:"size:200"` // Optional title for the release
Changes string `gorm:"type:text;not null"` // Markdown-formatted changes
CreatedById uint `gorm:"not null"`
CreatedBy *User `gorm:"foreignKey:CreatedById"`
}
// EmployeeDailyGroup tracks daily intranet-sourced group assignments for employees
// This is populated by querying the intranet API on each new day of user activity
// and determines which groups/children an employee can access on a given day.
type EmployeeDailyGroup struct {
gorm.Model
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
GroupID uint `gorm:"not null;index"`
Group Group `gorm:"foreignKey:GroupID"`
AssignmentDate time.Time `gorm:"type:date;not null;index"`
IsCoreTeam bool `gorm:"not null;default:false"` // true = Stammteam, false = substitute (day-limited)
}
// TableName specifies the table name for EmployeeDailyGroup
func (EmployeeDailyGroup) TableName() string {
return "employee_daily_groups"
}
// IntranetSettings stores settings for intranet API integration
// This is a singleton table - only one row should exist
type IntranetSettings struct {
gorm.Model
APIToken string `gorm:"size:500"` // Token for authenticating with intranet
APIURL string `gorm:"size:500;default:'https://www.wippidu.org/MA_Gruppe_Direct.php'"` // Intranet API endpoint
Enabled bool `gorm:"default:false"` // Master switch for intranet sync
}
// GetIntranetSettings retrieves the singleton intranet settings record
// Returns a default settings struct if no settings exist yet
func GetIntranetSettings() (*IntranetSettings, error) {
var settings IntranetSettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return &IntranetSettings{
APIURL: "https://www.wippidu.org/MA_Gruppe_Direct.php",
Enabled: false,
}, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveIntranetSettings creates or updates the singleton intranet settings record
func SaveIntranetSettings(settings *IntranetSettings) error {
var existing IntranetSettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return DB.Create(settings).Error
}
settings.ID = existing.ID
return DB.Save(settings).Error
}
// HolidaySettings stores settings for the public holiday import API
// This is a singleton table - only one row should exist
type HolidaySettings struct {
gorm.Model
APIURL string `gorm:"size:500;default:'https://date.nager.at'"`
CountryCode string `gorm:"size:5;default:'DE'"`
Enabled bool
}
// GetHolidaySettings retrieves the singleton holiday settings record
func GetHolidaySettings() (*HolidaySettings, error) {
var settings HolidaySettings
result := DB.First(&settings)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return &HolidaySettings{
APIURL: "https://date.nager.at",
CountryCode: "DE",
Enabled: true,
}, nil
}
return nil, result.Error
}
return &settings, nil
}
// SaveHolidaySettings creates or updates the singleton holiday settings record
func SaveHolidaySettings(settings *HolidaySettings) error {
var existing HolidaySettings
result := DB.First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return DB.Create(settings).Error
}
settings.ID = existing.ID
return DB.Save(settings).Error
}
var DB *gorm.DB
type Config struct {
Driver string
Host string
Port string
User string
Password string
DBName string
SSLMode string
}
func PopulateBasicData() {
roles := []Role{
{Name: "Anonymous"},
{Name: "Parent"},
{Name: "Employee"},
{Name: "GroupLead"},
{Name: "LocationLead"},
{Name: "Admin"},
}
for _, r := range roles {
check := Role{}
DB.Where("name = ?", r.Name).Find(&check)
if check.ID == 0 {
DB.Create(&r)
}
}
}
// AllModels returns every persisted GORM model in the application as
// a slice of interface{} pointers suitable for db.AutoMigrate.
//
// This is the single source of truth for "which models exist". Both
// production startup (InitDB) and every test setup feed off this list,
// so adding a new model is a one-line change here. Audit #464 found
// four parallel hand-curated AutoMigrate lists that had silently
// drifted apart; this function eliminates that class of bug.
//
// Order is preserved so it's easy to verify nothing was lost relative
// to the original list grouping (see the comments below).
func AllModels() []interface{} {
return []interface{}{
&User{},
&News{},
&Child{},
&Enrollment{},
&Group{},
&ChildCluster{},
&AbsenceNotification{},
&Role{},
&Location{},
&NewsRead{},
&Document{},
&BlackboardDocument{},
&Message{},
&MessageRead{},
&MessageTemplate{},
&ParentalLetter{},
&ParentalLetterRead{},
&PollOption{},
&PollVote{},
&SurveyQuestion{},
&SurveyResponse{},
&TableColumn{},
&TableRow{},
&TableCell{},
&Attachment{},
&Passwd{},
&Pin{},
&LocationDevice{},
&UserChild{},
// Intranet sync staging tables
&APIToken{},
&SyncPerson{},
&SyncParentChild{},
&SyncBelegung{},
&SyncEmployee{},
&SyncLocation{},
&SyncGroup{},
&SyncLocationLead{},
&SyncGroupLead{},
&SyncProcessingLog{},
&SyncReceiveLog{},
&SyncSettings{},
&RegistrationRequest{},
&EmailSettings{},
&InvitationCode{},
&EmployeeInvitationCode{},
&LegalSettings{},
&FAQSettings{},
&CalendarEvent{},
&DelegationSettings{},
&SessionSettings{},
// Employee Chat tables
&EmployeeChat{},
&ChatMessage{},
&ChatMessageRead{},
// Changelog
&ChangelogEntry{},
// Intranet daily group sync
&EmployeeDailyGroup{},
&IntranetSettings{},
&HolidaySettings{},
// Lead stand-in (Stellvertretung)
&LeadStandIn{},
// Database import/export audit log (local-only; never round-tripped)
&ImportExportAudit{},
}
}
func InitDB(cfg Config) {
var db *gorm.DB
var err error
switch cfg.Driver {
case "postgres":
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
cfg.Host, cfg.User, cfg.Password, cfg.DBName, cfg.Port, cfg.SSLMode,
)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
case "mysql":
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName,
)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
case "sqlite":
// Use DBName from config, or default to wippidu.db
dsn := cfg.DBName
if dsn == "" {
dsn = "wippidu.db"
}
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
default:
panic("No supported database driver specified")
}
if err != nil {
panic("failed to connect database")
}
err = db.AutoMigrate(AllModels()...)
if err != nil {
panic(err)
}
logger.Info("database migrated")
DB = db
PopulateBasicData()
InitAdminFromEnv()
migrateExistingUsersApproved()
migrateExistingLocationsDelegation()
migrateEmployeeLocationAccess()
}
// migrateExistingUsersApproved sets Approved=true for all existing activated users.
// This ensures backward compatibility when the Approved field is added.
// Only runs once - after migration, new users will have Approved=false by default.
func migrateExistingUsersApproved() {
result := DB.Model(&User{}).
Where("activated = ?", true).
Where("approved = ?", false).
Update("approved", true)
if result.RowsAffected > 0 {
logger.Info("migration: set Approved=true for existing users", "count", result.RowsAffected)
}
}
// migrateExistingLocationsDelegation sets delegation fields to true for existing locations.
// This ensures backward compatibility when the delegation fields are added.
// New locations will have these fields default to true via GORM tags.
func migrateExistingLocationsDelegation() {
// Update locations where delegation fields are false (not yet set)
result := DB.Model(&Location{}).
Where("delegate_users = ? AND delegate_children = ? AND delegate_groups = ? AND delegate_enrollments = ?",
false, false, false, false).
Updates(map[string]interface{}{
"delegate_users": true,
"delegate_children": true,
"delegate_groups": true,
"delegate_enrollments": true,
})
if result.RowsAffected > 0 {
logger.Info("migration: enabled delegation for existing locations", "count", result.RowsAffected)
}
}
// migrateEmployeeLocationAccess migrates the old AllowEmployeeLocationView boolean field
// to the new EmployeeLocationAccess string field with three access levels.
// - AllowEmployeeLocationView=true -> "all_employees"
// - AllowEmployeeLocationView=false or empty -> "location_leads" (default)
func migrateEmployeeLocationAccess() {
// Migrate locations that still have AllowEmployeeLocationView=true (old field)
// to EmployeeLocationAccess="all_employees"
result := DB.Model(&Location{}).
Where("allow_employee_location_view = ?", true).
Where("employee_location_access = ? OR employee_location_access = ? OR employee_location_access IS NULL",
"", "location_leads").
Update("employee_location_access", EmployeeAccessAllEmployees)
if result.RowsAffected > 0 {
logger.Info("migration: converted AllowEmployeeLocationView=true to EmployeeLocationAccess=all_employees",
"count", result.RowsAffected)
}
// Ensure all locations have a valid EmployeeLocationAccess value
result = DB.Model(&Location{}).
Where("employee_location_access = ? OR employee_location_access IS NULL", "").
Update("employee_location_access", EmployeeAccessLocationLeads)
if result.RowsAffected > 0 {
logger.Info("migration: set default EmployeeLocationAccess=location_leads", "count", result.RowsAffected)
}
}
package model
import (
"time"
"gorm.io/gorm"
)
// EmployeeInvitationCode represents a one-time invitation code for employee registration
type EmployeeInvitationCode struct {
gorm.Model
Code string `gorm:"type:varchar(64);uniqueIndex;not null"` // Secure random code (32 hex chars)
SyncEmployeeID uint `gorm:"not null;index"`
SyncEmployee SyncEmployee `gorm:"foreignKey:SyncEmployeeID"`
ExpiresAt time.Time `gorm:"not null;index"`
UsedAt *time.Time // When the code was used
UsedByEmail *string `gorm:"type:varchar(255)"` // Email of user who used it
RevokedAt *time.Time // When the code was revoked
RevokedByID *uint
RevokedBy *User `gorm:"foreignKey:RevokedByID"`
CreatedByID uint `gorm:"not null"`
CreatedBy User `gorm:"foreignKey:CreatedByID"`
}
// TableName specifies the table name for EmployeeInvitationCode
func (EmployeeInvitationCode) TableName() string {
return "employee_invitation_codes"
}
// IsValid returns true if the code can still be used
func (e *EmployeeInvitationCode) IsValid() bool {
return e.UsedAt == nil && e.RevokedAt == nil && time.Now().Before(e.ExpiresAt)
}
// IsUsed returns true if the code has been used
func (e *EmployeeInvitationCode) IsUsed() bool {
return e.UsedAt != nil
}
// IsRevoked returns true if the code has been revoked
func (e *EmployeeInvitationCode) IsRevoked() bool {
return e.RevokedAt != nil
}
// IsExpired returns true if the code has expired
func (e *EmployeeInvitationCode) IsExpired() bool {
return time.Now().After(e.ExpiresAt)
}
// StatusKey returns the i18n key for the current status
func (e *EmployeeInvitationCode) StatusKey() string {
if e.IsUsed() {
return "employee_invitation.status.used"
}
if e.IsRevoked() {
return "employee_invitation.status.revoked"
}
if e.IsExpired() {
return "employee_invitation.status.expired"
}
return "employee_invitation.status.active"
}
package model
import (
"time"
"gorm.io/gorm"
)
// ImportExportAudit records every export and import attempt on this
// instance (#459). Used on staging to answer "when was this data
// refreshed and by whom, and was it anonymized?".
//
// The audit log itself is a local-only table: it is never exported and
// never overwritten by an import, so the running history on staging
// survives across refreshes.
type ImportExportAudit struct {
gorm.Model
// Action is "export" or "import". Free string so future actions
// (e.g. "anonymize_only") can be added without a migration.
Action string `gorm:"size:32;not null;index"`
// UserID is a breadcrumb to the operator who triggered the action.
// Deliberately not a foreign key: an import wipes the users table, so
// the audit row must survive even when its operator no longer exists
// in the current data set. UserEmail carries the human-readable label.
UserID uint `gorm:"index"`
UserEmail string `gorm:"size:255"`
// Bytes is the size of the dump file moving through this action,
// for capacity-planning visibility.
Bytes int64
// SourceHost and SourceExportedAt come from the dump header on
// import. Empty for export.
SourceHost string `gorm:"size:255"`
SourceExportedAt time.Time
// Result is "success" or "failure". Written even on failure so the
// operator can see what went wrong in a single list view.
Result string `gorm:"size:32;not null"`
// ErrorMessage is populated on failure. Truncated to a sane size
// to keep audit rows compact.
ErrorMessage string `gorm:"size:2000"`
// DetailsJSON is a free-form JSON blob with action-specific data
// (row counts per table, which anonymizers ran, etc.). Kept
// unstructured so we can extend without schema churn.
DetailsJSON string `gorm:"type:text"`
}
// LatestSuccessfulImport returns the most recent successful import audit
// row, or nil if no import has ever succeeded on this instance. Used by
// the home pages to render the "data last refreshed" banner so any user
// landing on staging immediately understands what they're looking at.
//
// Returns nil (rather than panicking) if model.DB hasn't been
// initialized — startup-order safety for tests that render templates
// without a backing DB.
func LatestSuccessfulImport() *ImportExportAudit {
if DB == nil {
return nil
}
var audit ImportExportAudit
err := DB.Where("action = ? AND result = ?", "import", "success").
Order("created_at DESC").
First(&audit).Error
if err != nil {
return nil
}
return &audit
}
package model
import (
"time"
"gorm.io/gorm"
)
// InvitationCode represents a one-time invitation code for parent registration
type InvitationCode struct {
gorm.Model
Code string `gorm:"type:varchar(64);uniqueIndex;not null"` // Secure random code (32 hex chars)
ChildID uint `gorm:"not null;index"`
Child Child `gorm:"foreignKey:ChildID"`
RelationshipRole RelationshipRole `gorm:"type:varchar(20);not null"`
ExpiresAt time.Time `gorm:"not null;index"`
UsedAt *time.Time // When the code was used
UsedByEmail *string `gorm:"type:varchar(255)"` // Email of user who used it
RevokedAt *time.Time // When the code was revoked
RevokedByID *uint
RevokedBy *User `gorm:"foreignKey:RevokedByID"`
CreatedByID uint `gorm:"not null"`
CreatedBy User `gorm:"foreignKey:CreatedByID"`
}
// TableName specifies the table name for InvitationCode
func (InvitationCode) TableName() string {
return "invitation_codes"
}
// IsValid returns true if the code can still be used
func (i *InvitationCode) IsValid() bool {
return i.UsedAt == nil && i.RevokedAt == nil && time.Now().Before(i.ExpiresAt)
}
// IsUsed returns true if the code has been used
func (i *InvitationCode) IsUsed() bool {
return i.UsedAt != nil
}
// IsRevoked returns true if the code has been revoked
func (i *InvitationCode) IsRevoked() bool {
return i.RevokedAt != nil
}
// IsExpired returns true if the code has expired
func (i *InvitationCode) IsExpired() bool {
return time.Now().After(i.ExpiresAt)
}
// StatusKey returns the i18n key for the current status
func (i *InvitationCode) StatusKey() string {
if i.IsUsed() {
return "invitation.status.used"
}
if i.IsRevoked() {
return "invitation.status.revoked"
}
if i.IsExpired() {
return "invitation.status.expired"
}
return "invitation.status.active"
}
package model
import (
"time"
"gorm.io/gorm"
)
// LeadStandIn records that a GroupLead or LocationLead has authorised an
// Employee to write parental letters and news on their behalf for a bounded
// time window. The German UI calls this "Stellvertretung".
//
// A stand-in is active at moment T iff:
// - RevokedAt IS NULL
// - ValidFrom <= T
// - ValidUntil IS NULL OR ValidUntil >= T
//
// This is a narrow, opt-in extension to the existing role-based authorization.
// It does NOT change child access, group settings, or any other lead capability.
type LeadStandIn struct {
gorm.Model
DelegatorID uint `gorm:"index;not null"`
Delegator User `gorm:"foreignKey:DelegatorID"`
DelegateID uint `gorm:"index;not null"`
Delegate User `gorm:"foreignKey:DelegateID"`
ValidFrom time.Time `gorm:"not null"`
ValidUntil *time.Time
RevokedAt *time.Time
Note string `gorm:"size:500"`
}
// IsActiveAt reports whether the stand-in is in effect at the given moment.
func (s *LeadStandIn) IsActiveAt(t time.Time) bool {
if s.RevokedAt != nil {
return false
}
if s.ValidFrom.After(t) {
return false
}
if s.ValidUntil != nil && s.ValidUntil.Before(t) {
return false
}
return true
}
// ActiveStandInsForDelegate returns every currently-active stand-in record
// granted to the given user, with Delegator preloaded.
func ActiveStandInsForDelegate(db *gorm.DB, delegateID uint) ([]LeadStandIn, error) {
now := time.Now()
var standIns []LeadStandIn
err := db.Preload("Delegator").Preload("Delegator.Roles").
Where("delegate_id = ? AND revoked_at IS NULL AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)",
delegateID, now, now).
Find(&standIns).Error
return standIns, err
}
// ActiveStandInsForDelegator returns every currently-active stand-in granted
// by the given user, with Delegate preloaded.
func ActiveStandInsForDelegator(db *gorm.DB, delegatorID uint) ([]LeadStandIn, error) {
now := time.Now()
var standIns []LeadStandIn
err := db.Preload("Delegate").
Where("delegator_id = ? AND revoked_at IS NULL AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)",
delegatorID, now, now).
Find(&standIns).Error
return standIns, err
}
// HasActiveStandInFrom returns the delegating User if `delegateID` currently
// has an active stand-in from someone whose effective role at the given
// location/group is GroupLead or LocationLead. Returns nil if no such
// stand-in exists.
//
// Resolution order:
// - If groupID is set, the delegator must lead that group OR lead its location.
// - Else if locationID is set, the delegator must lead that location.
//
// On any error during the lookup, the function returns nil with the error.
// Callers should treat (nil, nil) as "no active stand-in".
func HasActiveStandInFrom(db *gorm.DB, delegateID uint, groupID, locationID *uint) (*User, error) {
standIns, err := ActiveStandInsForDelegate(db, delegateID)
if err != nil || len(standIns) == 0 {
return nil, err
}
// Resolve the target location for the lead-authority check.
var targetLocationID uint
switch {
case groupID != nil && *groupID > 0:
var g Group
if err := db.Select("location_id").First(&g, *groupID).Error; err != nil {
return nil, err
}
targetLocationID = g.LocationId
case locationID != nil && *locationID > 0:
targetLocationID = *locationID
default:
return nil, nil
}
for i := range standIns {
s := &standIns[i]
if leadsScope(db, &s.Delegator, groupID, targetLocationID) {
return &s.Delegator, nil
}
}
return nil, nil
}
// ReconcileStandInsForUser inspects the user's current state and revokes any
// stand-ins that should no longer be active because of it. Idempotent. Safe
// to call after any User update.
//
// - User is Deactivated → revoke every stand-in where they are delegator OR delegate.
// - User no longer has GroupLead or LocationLead role → revoke every stand-in
// where they are the delegator (since they no longer have a lead capability
// to delegate).
//
// The user must be loaded with Roles preloaded for the role check to work.
// Returns the number of stand-in records affected.
func ReconcileStandInsForUser(db *gorm.DB, userID uint) (int64, error) {
var u User
if err := db.Preload("Roles").First(&u, userID).Error; err != nil {
return 0, err
}
now := time.Now()
var affected int64
// Case 1: deactivated user → revoke both directions.
if u.Deactivated || !u.Activated {
res := db.Model(&LeadStandIn{}).
Where("(delegator_id = ? OR delegate_id = ?) AND revoked_at IS NULL", userID, userID).
Update("revoked_at", now)
if res.Error != nil {
return affected, res.Error
}
affected += res.RowsAffected
return affected, nil
}
// Case 2: user is no longer a lead → revoke their outgoing stand-ins.
if !u.IsGroupLeader() && !u.IsHouseLeader() && !u.IsAdmin() {
res := db.Model(&LeadStandIn{}).
Where("delegator_id = ? AND revoked_at IS NULL", userID).
Update("revoked_at", now)
if res.Error != nil {
return affected, res.Error
}
affected += res.RowsAffected
}
return affected, nil
}
// leadsScope reports whether the user has direct (non-stand-in) lead authority
// over the given group or location. LocationLead of the location wins for both
// group and location-wide scopes; GroupLead matches only when the group is one
// they lead. Admin always passes.
func leadsScope(db *gorm.DB, u *User, groupID *uint, locationID uint) bool {
if u == nil {
return false
}
if u.IsAdmin() {
return true
}
// LocationLead of the target location?
if u.IsHouseLeader() {
var count int64
db.Model(&Location{}).
Where("id = ? AND (lead_id = ? OR lead2nd_id = ?)", locationID, u.ID, u.ID).
Count(&count)
if count > 0 {
return true
}
}
// GroupLead of the target group? Only meaningful when a specific group is requested.
if groupID != nil && *groupID > 0 && u.IsGroupLeader() {
var count int64
db.Model(&Group{}).
Where("id = ? AND lead_id = ?", *groupID, u.ID).
Count(&count)
if count > 0 {
return true
}
}
return false
}
package model
import (
"time"
"gorm.io/gorm"
)
// RegistrationStatus represents the workflow state of a registration request
type RegistrationStatus string
const (
RegistrationStatusPending RegistrationStatus = "pending"
RegistrationStatusApproved RegistrationStatus = "approved"
RegistrationStatusRejected RegistrationStatus = "rejected"
RegistrationStatusAutoApproved RegistrationStatus = "auto-approved" // Via invitation code
)
// RegistrationRequestType indicates whether registering as parent or employee
type RegistrationRequestType string
const (
RegistrationTypeParent RegistrationRequestType = "parent"
RegistrationTypeEmployee RegistrationRequestType = "employee"
)
// RegistrationRequest represents a self-registration request from a parent or employee
// that requires staff approval before account creation.
type RegistrationRequest struct {
gorm.Model
// Applicant info
FirstName string `gorm:"type:varchar(100);not null"`
LastName string `gorm:"type:varchar(100);not null"`
Email string `gorm:"type:varchar(255);not null;index"`
RequestType RegistrationRequestType `gorm:"type:varchar(20);not null"` // "parent" or "employee"
// Child info (for parent registrations)
ChildFirstName string `gorm:"type:varchar(100)"`
ChildLastName string `gorm:"type:varchar(100)"`
ChildBirthday *time.Time `gorm:"type:date"`
LocationID uint `gorm:"not null;index"`
Location Location `gorm:"foreignKey:LocationID"`
GroupID *uint `gorm:"index"`
Group *Group `gorm:"foreignKey:GroupID"`
RelationshipRole RelationshipRole `gorm:"type:varchar(20)"` // mother, father, guardian, etc.
// Matching results (computed at submission, displayed in admin UI)
ChildMatch bool // Child found in Child table by name+birthday+group
ChildMatchID *uint // Matched Child.ID if found
ParentSyncMatch bool // Parent found in SyncPerson by name
RelationshipMatch bool // Relationship found in SyncParentChild
// Workflow
Status RegistrationStatus `gorm:"type:varchar(20);not null;default:'pending';index"`
ReviewedByID *uint
ReviewedBy *User `gorm:"foreignKey:ReviewedByID"`
ReviewedAt *time.Time
RejectReason *string `gorm:"type:text"`
ExistingUserID *uint // For reregistration: existing deactivated user to reactivate
ExistingUser *User `gorm:"foreignKey:ExistingUserID"`
// The User created upon approval
CreatedUserID *uint
CreatedUser *User `gorm:"foreignKey:CreatedUserID"`
}
// TableName specifies the table name for RegistrationRequest
func (RegistrationRequest) TableName() string {
return "registration_requests"
}
// IsParentRegistration returns true if this is a parent registration
func (r *RegistrationRequest) IsParentRegistration() bool {
return r.RequestType == RegistrationTypeParent
}
// IsEmployeeRegistration returns true if this is an employee registration
func (r *RegistrationRequest) IsEmployeeRegistration() bool {
return r.RequestType == RegistrationTypeEmployee
}
// IsPending returns true if the request is still pending review
func (r *RegistrationRequest) IsPending() bool {
return r.Status == RegistrationStatusPending
}
// IsApproved returns true if the request was approved
func (r *RegistrationRequest) IsApproved() bool {
return r.Status == RegistrationStatusApproved
}
// IsRejected returns true if the request was rejected
func (r *RegistrationRequest) IsRejected() bool {
return r.Status == RegistrationStatusRejected
}
// IsAutoApproved returns true if the request was auto-approved via invitation code
func (r *RegistrationRequest) IsAutoApproved() bool {
return r.Status == RegistrationStatusAutoApproved
}
// FullName returns the applicant's full name
func (r *RegistrationRequest) FullName() string {
return r.FirstName + " " + r.LastName
}
// ChildFullName returns the child's full name (for parent registrations)
func (r *RegistrationRequest) ChildFullName() string {
if r.ChildFirstName == "" && r.ChildLastName == "" {
return ""
}
return r.ChildFirstName + " " + r.ChildLastName
}
package model
import (
"time"
"gorm.io/gorm"
)
// =============================================================================
// API Token Model
// =============================================================================
// APIToken represents an authentication token for external API access (intranet sync)
type APIToken struct {
gorm.Model
Name string `gorm:"type:varchar(100);not null"` // Friendly name for the token
TokenHash string `gorm:"type:varchar(255);not null;uniqueIndex"` // bcrypt hash of token
IPAllowlist string `gorm:"type:text"` // Comma-separated IP addresses/CIDRs (empty = all allowed)
LastUsedAt *time.Time `gorm:"index"` // Last successful usage
LastUsedIP string `gorm:"type:varchar(45)"` // Last IP that used this token (supports IPv6)
Active bool `gorm:"default:true;index"` // Token enabled/disabled
CreatedByID uint `gorm:"not null"` // Admin who created the token
CreatedBy User `gorm:"foreignKey:CreatedByID"`
}
// =============================================================================
// Staging Tables for Intranet Data Sync
// =============================================================================
// SyncPerson rolle constants - numeric IDs from intranet system
const (
SyncRolleChild = "1" // Child
SyncRolleFather = "2" // Father (parent)
SyncRolleMother = "3" // Mother (parent)
SyncRolleOther = "4" // Other relationship (treated as parent)
)
// SyncRolleParents contains all rolle values that represent parents
var SyncRolleParents = []string{SyncRolleFather, SyncRolleMother, SyncRolleOther}
// SyncPerson - Staging table for persons (parents, children) from intranet
type SyncPerson struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ExternalID string `gorm:"type:varchar(50);not null;uniqueIndex;column:external_id"` // ID from intranet
Vorname string `gorm:"type:varchar(100)"`
Nachname string `gorm:"type:varchar(100)"`
Geburtstag *time.Time `gorm:"type:date"`
Rolle string `gorm:"type:varchar(20)"` // 1=child, 2=father, 3=mother, 4=other
Comments string `gorm:"type:text"`
SyncedAt time.Time `gorm:"autoUpdateTime"` // Last sync timestamp
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncPerson
func (SyncPerson) TableName() string {
return "sync_persons"
}
// SyncParentChild - Staging table for parent-child relationships from intranet
type SyncParentChild struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ElternID string `gorm:"type:varchar(50);not null;index;column:eltern_id"` // Parent external ID
KindID string `gorm:"type:varchar(50);not null;index;column:kind_id"` // Child external ID
Von *time.Time `gorm:"type:date"` // Validity start
Bis *time.Time `gorm:"type:date"` // Validity end
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncParentChild
func (SyncParentChild) TableName() string {
return "sync_parent_children"
}
// UniqueKey returns a composite key for upsert matching
func (s *SyncParentChild) UniqueKey() string {
return s.ElternID + "-" + s.KindID
}
// SyncBelegung - Staging table for group assignments (combined krp + ue3) from intranet
// This table combines two intranet tables with different ID schemas
type SyncBelegung struct {
IDInternal uint `gorm:"primaryKey;autoIncrement;column:id_internal"`
IDKrp *string `gorm:"type:varchar(50);column:id_krp;index"` // External ID for krp records (nullable)
IDUe3 *string `gorm:"type:varchar(50);column:id_ue3;index"` // External ID for ue3 records (nullable)
KindID string `gorm:"type:varchar(50);not null;index;column:kind_id"` // Child external ID
GruppenID string `gorm:"type:varchar(50);not null;index;column:gruppen_id"` // Group external ID
TageBinaer int `gorm:"column:tage_binaer"` // Binary representation of care days
AnzahlTage int `gorm:"column:anzahl_tage"` // Number of care days
Von *time.Time `gorm:"type:date"` // Contract/assignment start
Bis *time.Time `gorm:"type:date"` // Contract/assignment end
Status int `gorm:"default:0"` // 2=planning, 3=in contract
Comments string `gorm:"type:text"`
SomeID *string `gorm:"type:varchar(50);column:some_id"`
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncBelegung
func (SyncBelegung) TableName() string {
return "sync_belegungen"
}
// SyncEmployee - Staging table for employee data from intranet
type SyncEmployee struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ExternalID string `gorm:"type:varchar(50);not null;uniqueIndex;column:external_id"`
Name string `gorm:"type:varchar(200)"` // Full name (may be redundant with Vorname+Nachname)
Vorname string `gorm:"type:varchar(100)"`
Nachname string `gorm:"type:varchar(100)"`
Qualifikation string `gorm:"type:varchar(100)"`
Von *time.Time `gorm:"type:date"` // Employment/access start
Bis *time.Time `gorm:"type:date"` // Employment/access end
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncEmployee
func (SyncEmployee) TableName() string {
return "sync_employees"
}
// SyncLocation - Staging table for locations/facilities from intranet
type SyncLocation struct {
ID uint `gorm:"primaryKey;autoIncrement"`
EinrichtungsID string `gorm:"type:varchar(50);not null;uniqueIndex;column:einrichtungs_id"` // Facility external ID
Name string `gorm:"type:varchar(200)"`
Adresse string `gorm:"type:text;column:adresse"`
Reihenfolge int `gorm:"default:0"` // Sort order
GVon *time.Time `gorm:"type:date;column:g_von"` // Valid from
GBis *time.Time `gorm:"type:date;column:g_bis"` // Valid until
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncLocation
func (SyncLocation) TableName() string {
return "sync_locations"
}
// SyncGroup - Staging table for groups from intranet
type SyncGroup struct {
ID uint `gorm:"primaryKey;autoIncrement"`
GruppenID string `gorm:"type:varchar(50);not null;uniqueIndex;column:gruppen_id"` // Group external ID
EinrichtungsID string `gorm:"type:varchar(50);not null;index;column:einrichtungs_id"` // Location external ID
EArtID *string `gorm:"type:varchar(50);column:e_art_id"` // Type ID
Reihenfolge int `gorm:"default:0"` // Sort order
Name string `gorm:"type:varchar(200)"`
OeZ string `gorm:"type:varchar(100);column:oez"` // Opening hours (Öffnungszeit)
GVon *time.Time `gorm:"type:date;column:g_von"` // Valid from
GBis *time.Time `gorm:"type:date;column:g_bis"` // Valid until
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncGroup
func (SyncGroup) TableName() string {
return "sync_groups"
}
// SyncLocationLead - Staging table for location leadership assignments from intranet
type SyncLocationLead struct {
ID uint `gorm:"primaryKey;autoIncrement"`
EinrichtungsID string `gorm:"type:varchar(50);not null;index;column:einrichtungs_id"` // Location external ID
MitarbeiterID string `gorm:"type:varchar(50);not null;index;column:mitarbeiter_id"` // Employee external ID (lead)
StellvertreterID *string `gorm:"type:varchar(50);column:stellvertreter_id"` // Deputy external ID
StellvertreterAnteil *int `gorm:"column:stellvertreter_anteil"` // Deputy share percentage
GVon *time.Time `gorm:"type:date;column:g_von"` // Valid from
GBis *time.Time `gorm:"type:date;column:g_bis"` // Valid until
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncLocationLead
func (SyncLocationLead) TableName() string {
return "sync_location_leads"
}
// UniqueKey returns a composite key for upsert matching
func (s *SyncLocationLead) UniqueKey() string {
return s.EinrichtungsID + "-" + s.MitarbeiterID
}
// SyncGroupLead - Staging table for group leadership assignments from intranet
type SyncGroupLead struct {
ID uint `gorm:"primaryKey;autoIncrement"`
GruppenID string `gorm:"type:varchar(50);not null;index;column:gruppen_id"` // Group external ID
MitarbeiterID string `gorm:"type:varchar(50);not null;index;column:mitarbeiter_id"` // Employee external ID (lead)
GVon *time.Time `gorm:"type:date;column:g_von"` // Valid from
GBis *time.Time `gorm:"type:date;column:g_bis"` // Valid until
SyncedAt time.Time `gorm:"autoUpdateTime"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName specifies the table name for SyncGroupLead
func (SyncGroupLead) TableName() string {
return "sync_group_leads"
}
// UniqueKey returns a composite key for upsert matching
func (s *SyncGroupLead) UniqueKey() string {
return s.GruppenID + "-" + s.MitarbeiterID
}
// =============================================================================
// Processing Log Model
// =============================================================================
// SyncProcessingLog tracks the history of staging data processing operations
type SyncProcessingLog struct {
ID uint `gorm:"primaryKey;autoIncrement"`
DataType string `gorm:"type:varchar(50);not null;index"` // "location", "group", "child", "child_group", "all"
StartedAt time.Time `gorm:"not null"`
CompletedAt *time.Time
Status string `gorm:"type:varchar(20);not null;index"` // "running", "completed", "failed"
RecordsTotal int `gorm:"default:0"`
Created int `gorm:"default:0"`
Updated int `gorm:"default:0"`
Skipped int `gorm:"default:0"`
Errored int `gorm:"default:0"`
ErrorDetails string `gorm:"type:text"`
TriggeredByID uint `gorm:"not null"`
TriggeredBy User `gorm:"foreignKey:TriggeredByID"`
}
// TableName specifies the table name for SyncProcessingLog
func (SyncProcessingLog) TableName() string {
return "sync_processing_logs"
}
// =============================================================================
// Receive Log Model
// =============================================================================
// SyncReceiveLog tracks every intranet API data push (reception)
type SyncReceiveLog struct {
ID uint `gorm:"primaryKey;autoIncrement"`
DataType string `gorm:"type:varchar(50);not null;index"`
ReceivedAt time.Time `gorm:"not null;index"`
RecordsReceived int `gorm:"default:0"`
Inserted int `gorm:"default:0"`
Updated int `gorm:"default:0"`
Errored int `gorm:"default:0"`
TokenName string `gorm:"type:varchar(100)"`
SourceIP string `gorm:"type:varchar(45)"`
ErrorDetails string `gorm:"type:text"`
}
// TableName specifies the table name for SyncReceiveLog
func (SyncReceiveLog) TableName() string {
return "sync_receive_logs"
}
package model
import (
"testing"
"time"
"wippidu_app_backend/internal/testhelpers"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TimePtr returns a pointer to the given time.Time - useful for setting ValidUntil/ValidFrom in tests
func TimePtr(t time.Time) *time.Time {
return &t
}
// SetupTestDBWithModels creates a test database with every model
// migrated. Uses AllModels() so this stays in sync with InitDB and
// the controller / dbexport / dbimport test helpers automatically.
func SetupTestDBWithModels(t *testing.T) *gorm.DB {
db := testhelpers.SetupTestDB(t)
err := db.AutoMigrate(AllModels()...)
require.NoError(t, err)
// Set global DB
DB = db
// Populate basic roles
PopulateBasicData()
return db
}
// CreateTestUser creates a test user with the specified role
func CreateTestUser(t *testing.T, db *gorm.DB, email string, roleName string) *User {
user := &User{
Email: email,
Address: "Test Address 123",
Birthday: time.Now().AddDate(-30, 0, 0),
ActivatedAt: time.Now(),
ValidUntil: time.Now().AddDate(1, 0, 0),
Activated: true,
}
err := db.Create(user).Error
require.NoError(t, err)
// Assign role if specified
if roleName != "" {
role := &Role{}
err = db.Where("name = ?", roleName).First(role).Error
require.NoError(t, err)
err = db.Model(user).Association("Roles").Append(role)
require.NoError(t, err)
// Reload user with roles
db.Preload("Roles").First(user, user.ID)
}
return user
}
package model
// User models for the Wippidu Kita App
//
// [impl->dsn~datenbank-design~1]
import (
"fmt"
"time"
"wippidu_app_backend/internal/logger"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex"`
FirstName string
LastName string
Address string
PhoneNumber string `gorm:"size:50"`
Birthday time.Time
Children []*Child `gorm:"many2many:user_children;joinForeignKey:UserID;joinReferences:ChildID"`
ActivatedAt time.Time
ValidUntil time.Time
Activated bool
ActivateCode *string
Roles []*Role `gorm:"many2many:user_roles;"`
Language string `gorm:"default:de"`
ExternalID *string `gorm:"uniqueIndex;size:100"` // Intranet system ID for linking to SyncEmployee/SyncPerson
ForcePasswordChange bool `gorm:"default:false"` // Force user to change password on next login
NotifyByEmail bool `gorm:"default:false"` // Opt-in for email notifications on published items
PendingEmail *string `gorm:"size:255"` // New email awaiting verification
PendingEmailCode *string `gorm:"size:255;index"` // Verification code for email change
// Approval fields for registration workflow
Approved bool `gorm:"default:false"` // Has admin approved this user?
ApprovedAt *time.Time // When was user approved?
ApprovedByID *uint // Who approved this user?
// Deactivation fields (manual administrative block, separate from Activated)
Deactivated bool `gorm:"default:false"`
DeactivatedAt *time.Time
DeactivatedByID *uint
// Colleague message notification tracking
GroupMessagesLastViewedAt *time.Time // When employee last viewed group colleague messages
LocationMessagesLastViewedAt *time.Time // When employee last viewed location colleague messages
// Intranet daily group sync tracking
IntranetRefreshDate *time.Time `gorm:"type:date"` // Last successful intranet group refresh date
IntranetRefreshFailed bool `gorm:"default:false"` // True if last refresh attempt failed
isLoggedIn bool
}
// IsDeactivated returns true if the user account has been manually deactivated by an admin.
func (u User) IsDeactivated() bool {
return u.Deactivated
}
func (u User) IsLoggedIn() bool {
return u.isLoggedIn
}
// DisplayName returns the user's full name if available, otherwise falls back to email.
// This method is used in templates to show a human-readable identifier for the user.
func (u User) DisplayName() string {
if u.FirstName != "" && u.LastName != "" {
return u.FirstName + " " + u.LastName
}
if u.FirstName != "" {
return u.FirstName
}
if u.LastName != "" {
return u.LastName
}
return u.Email
}
// DisplayNameWithChildren returns the user's display name with their children's first names
// in parentheses, e.g. "Emma Müller (Max, Lisa)". This is useful for employees to quickly
// identify which parent belongs to which children. Requires Children relationship to be preloaded.
func (u User) DisplayNameWithChildren() string {
name := u.DisplayName()
// If no children loaded, just return the name
if len(u.Children) == 0 {
return name
}
// Build comma-separated list of children's first names
childNames := ""
for i, child := range u.Children {
if i > 0 {
childNames += ", "
}
childNames += child.FirstName
}
return fmt.Sprintf("%s (%s)", name, childNames)
}
func (u *User) SetNotLoggedIn() {
u.isLoggedIn = false
}
func (u *User) SetLoggedIn() {
u.isLoggedIn = true
}
func (u User) HasRole(r string) bool {
for _, rl := range u.Roles {
if r == rl.Name {
return true
}
}
return false
}
func (u User) IsParent() bool {
return u.HasRole("Parent")
}
func (u User) IsEmployee() bool {
return u.HasRole("Employee")
}
func (u User) IsGroupLeader() bool {
return u.HasRole("GroupLead")
}
func (u User) IsHouseLeader() bool {
return u.HasRole("LocationLead")
}
func (u User) IsAdmin() bool {
return u.HasRole("Admin")
}
// IsApproved returns true if the user account has been approved by an admin.
// Unapproved users can log in but only see a "pending approval" page.
func (u User) IsApproved() bool {
return u.Approved
}
// GetLocationIDs returns the location IDs that a user has access to based on their role and group assignments.
// Returns:
// - nil for Admin (unrestricted access to all locations)
// - nil for Parent (uses different access pattern via user_children)
// - []uint of location IDs for Employee/GroupLead/LocationLead (derived from assigned groups)
func (u User) GetLocationIDs(db *gorm.DB) ([]uint, error) {
// Admin has unrestricted access - return nil to indicate no filtering needed
if u.IsAdmin() {
return nil, nil
}
// Parent uses different access pattern (user_children) - return nil
if u.IsParent() && !u.IsEmployee() {
return nil, nil
}
// For staff roles (Employee, GroupLead, LocationLead):
// Get groups assigned to this user via group_teachers table
var groups []Group
err := db.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", u.ID).
Find(&groups).Error
if err != nil {
return nil, err
}
// Extract unique location IDs from those groups
locationIDsMap := make(map[uint]bool)
for _, group := range groups {
locationIDsMap[group.LocationId] = true
}
// For employees with ExternalID, also include locations from daily intranet sync
// This handles substitute assignments that are only valid for the current day
if u.ExternalID != nil && *u.ExternalID != "" {
today := time.Now().Truncate(24 * time.Hour)
var dailyGroups []EmployeeDailyGroup
err := db.Preload("Group").
Where("user_id = ? AND assignment_date = ?", u.ID, today).
Find(&dailyGroups).Error
if err == nil {
for _, dg := range dailyGroups {
if dg.Group.LocationId != 0 {
locationIDsMap[dg.Group.LocationId] = true
}
}
}
}
// For LocationLeads, also check locations they lead via lead_id/lead2nd_id
// This ensures location leads can access all children at their assigned locations,
// not just children in groups they're directly assigned to via group_teachers
if u.IsHouseLeader() {
// Check locations where user is Lead or Lead2nd
var leadLocations []Location
err := db.Where("lead_id = ? OR lead2nd_id = ?", u.ID, u.ID).Find(&leadLocations).Error
if err == nil {
for _, loc := range leadLocations {
locationIDsMap[loc.ID] = true
}
}
// Also check sync_location_leads for users with ExternalID (intranet sync)
if u.ExternalID != nil && *u.ExternalID != "" {
var syncLocations []Location
err := db.Joins("JOIN sync_location_leads ON locations.external_id = sync_location_leads.einrichtungs_id").
Where("sync_location_leads.mitarbeiter_id = ?", *u.ExternalID).
Where("sync_location_leads.g_von IS NULL OR sync_location_leads.g_von <= NOW()").
Where("sync_location_leads.g_bis IS NULL OR sync_location_leads.g_bis >= NOW()").
Find(&syncLocations).Error
if err == nil {
for _, loc := range syncLocations {
locationIDsMap[loc.ID] = true
}
}
}
}
// Convert map to slice
locationIDs := make([]uint, 0, len(locationIDsMap))
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
return locationIDs, nil
}
// GetEmployeeGroups returns the groups an employee is assigned to for VIEWING children.
// Always includes permanent group_teachers assignments as baseline.
// For intranet-linked employees (with ExternalID), also adds today's daily assignments.
// Returns empty slice if user is not a staff member (Employee, GroupLead, or LocationLead).
// NOTE: For content creation rights (news, parental letters, etc.), use GetEmployeeCoreTeamGroups instead.
func (u User) GetEmployeeGroups(db *gorm.DB) ([]Group, error) {
// Check if user is any type of staff (Employee, GroupLead, or LocationLead)
if !u.IsEmployee() && !u.IsGroupLeader() && !u.IsHouseLeader() {
return []Group{}, nil
}
var groups []Group
// Start with permanent group_teachers assignments (always the baseline)
err := db.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", u.ID).
Preload("Location").
Order("groups.id ASC").
Find(&groups).Error
if err != nil {
return nil, err
}
// For intranet-linked employees, also include today's daily assignments
if u.ExternalID != nil && *u.ExternalID != "" {
today := time.Now().Truncate(24 * time.Hour)
var dailyGroups []Group
err := db.Joins("JOIN employee_daily_groups ON groups.id = employee_daily_groups.group_id").
Where("employee_daily_groups.user_id = ? AND employee_daily_groups.assignment_date = ?", u.ID, today).
Preload("Location").
Find(&dailyGroups).Error
if err == nil {
seen := make(map[uint]bool)
for _, g := range groups {
seen[g.ID] = true
}
for _, g := range dailyGroups {
if !seen[g.ID] {
groups = append(groups, g)
}
}
}
}
return groups, nil
}
// GetEmployeeCoreTeamGroups returns only the groups where the employee is a CORE TEAM member.
// Always includes permanent group_teachers assignments as baseline (treated as core team).
// For intranet-linked employees (with ExternalID), also adds today's Stammteam=true daily assignments.
// Use this for content creation rights (news, parental letters, calendar entries).
// Substitute employees should NOT have creation rights in groups they're only substituting in.
func (u User) GetEmployeeCoreTeamGroups(db *gorm.DB) ([]Group, error) {
// Check if user is any type of staff (Employee, GroupLead, or LocationLead)
if !u.IsEmployee() && !u.IsGroupLeader() && !u.IsHouseLeader() {
return []Group{}, nil
}
var groups []Group
// Start with permanent group_teachers assignments (always the baseline, treated as core team)
err := db.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", u.ID).
Preload("Location").
Order("groups.id ASC").
Find(&groups).Error
if err != nil {
return nil, err
}
// For intranet-linked employees, also include today's core team daily assignments
if u.ExternalID != nil && *u.ExternalID != "" {
today := time.Now().Truncate(24 * time.Hour)
var dailyGroups []Group
err := db.Joins("JOIN employee_daily_groups ON groups.id = employee_daily_groups.group_id").
Where("employee_daily_groups.user_id = ? AND employee_daily_groups.assignment_date = ? AND employee_daily_groups.is_core_team = ?", u.ID, today, true).
Preload("Location").
Find(&dailyGroups).Error
if err == nil {
seen := make(map[uint]bool)
for _, g := range groups {
seen[g.ID] = true
}
for _, g := range dailyGroups {
if !seen[g.ID] {
groups = append(groups, g)
}
}
}
}
return groups, nil
}
func UserFromUserIdent(ident uint) (*User, error) {
user := User{}
DB.Where("id = ?", ident).First(&user)
if user.ID == 0 {
return nil, fmt.Errorf("User not found")
}
user.isLoggedIn = true
return &user, nil
}
func NewDummyUser() *User {
user := User{}
user.SetNotLoggedIn()
return &user
}
// CanUserAccessChild checks if a user has permission to access a child's data.
// Uses location-based scope for staff roles and relationship-based access for parents.
// Returns true if:
// - User is Admin (unrestricted access to all children)
// - User is Employee/GroupLead/LocationLead AND child is in one of their assigned locations
// - User is Parent AND child is in their user_children relationship
// For dual-role users (e.g., Employee+Parent), access is granted if EITHER condition is met.
// Returns false otherwise.
//
// [impl->dsn~zugriffsmanagement-design~1]
// [impl->dsn~rechtevergabe-design~1]
func CanUserAccessChild(db *gorm.DB, userID uint, childID uint) (bool, error) {
// Fetch user with roles
user := User{}
if err := db.Preload("Roles").Where("id = ?", userID).First(&user).Error; err != nil {
return false, err
}
// Admin has unrestricted access to all children
if user.IsAdmin() {
return true, nil
}
// Check staff access (Employee, GroupLead, LocationLead): location-based scope
hasStaffAccess := false
if user.IsEmployee() || user.IsGroupLeader() || user.IsHouseLeader() {
// Get user's assigned location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil {
return false, err
}
if len(locationIDs) > 0 {
// Get child with their group to check location
var child Child
if err := db.Preload("Group").Where("id = ?", childID).First(&child).Error; err != nil {
return false, err
}
// Check if child's group belongs to one of user's locations
if child.Group != nil {
for _, locID := range locationIDs {
if child.Group.LocationId == locID {
hasStaffAccess = true
break
}
}
}
}
}
// Check parent access: relationship via user_children table with validity date check
hasParentAccess := false
if user.IsParent() {
now := time.Now()
var count int64
err := db.Table("user_children").
Where("user_id = ? AND child_id = ?", userID, childID).
Where("(valid_from IS NULL OR valid_from <= ?)", now).
Where("(valid_until IS NULL OR valid_until >= ?)", now).
Count(&count).Error
if err != nil {
return false, err
}
hasParentAccess = count > 0
}
// Allow access if EITHER staff or parent access is granted
return hasStaffAccess || hasParentAccess, nil
}
// HasValidChildren returns true if the user has at least one child with a valid
// parent-child relationship (via user_children.valid_from/valid_until).
func HasValidChildren(db *gorm.DB, userID uint) (bool, error) {
now := time.Now()
var count int64
err := db.Table("user_children").
Where("user_id = ?", userID).
Where("(valid_from IS NULL OR valid_from <= ?)", now).
Where("(valid_until IS NULL OR valid_until >= ?)", now).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// GetValidChildrenForUser returns children for a parent user, filtered by parent-child relationship validity.
// This filters by the user_children join table's valid_from/valid_until dates, which represent
// when the parent has access to the child (not the child's group enrollment validity).
// The child's group enrollment validity is handled separately via the Enrollment table
// and cached in Child.ValidFrom/ValidUntil fields.
func GetValidChildrenForUser(db *gorm.DB, userID uint) ([]*Child, error) {
now := time.Now()
// Get valid child IDs from user_children join table
var validChildIDs []uint
err := db.Table("user_children").
Select("child_id").
Where("user_id = ?", userID).
Where("(valid_from IS NULL OR valid_from <= ?)", now).
Where("(valid_until IS NULL OR valid_until >= ?)", now).
Pluck("child_id", &validChildIDs).Error
if err != nil {
return nil, err
}
if len(validChildIDs) == 0 {
return []*Child{}, nil
}
// Load children with their relationships
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var children []*Child
err = db.Preload("Group.Location").
Preload("AbsenceNotifications", "from_date <= ? AND to_date >= ? AND deleted_at IS NULL", now, startOfToday).
Where("id IN ?", validChildIDs).
Find(&children).Error
if err != nil {
return nil, err
}
return children, nil
}
type Passwd struct {
gorm.Model
UserId uint
User User `gorm:"foreignKey:UserId"`
PassHash string
}
type Pin struct {
gorm.Model
UserId uint
User User `gorm:"foreignKey:UserId"`
PinHash string
}
// Role represents user roles in the system (Parent, Employee, GroupLead, LocationLead, Admin)
//
// [impl->dsn~rechtevergabe-design~1]
type Role struct {
gorm.Model
Name string
}
// DelegationType represents the type of admin function that can be delegated
type DelegationType string
const (
DelegationUsers DelegationType = "users"
DelegationChildren DelegationType = "children"
DelegationGroups DelegationType = "groups"
DelegationEnrollments DelegationType = "enrollments"
)
// CanAccessDelegatedAdmin checks if a user can access delegated admin functions.
// Returns true if:
// - User is Admin (always allowed)
// - User is LocationLead AND the delegation is enabled AND user leads the specified location(s)
// If locationID is nil, checks if user can access any delegated admin for their locations.
func (u User) CanAccessDelegatedAdmin(db *gorm.DB, delegation DelegationType, locationID *uint) bool {
// Admin always has full access
if u.IsAdmin() {
return true
}
// Only LocationLeads can have delegated admin access
if !u.IsHouseLeader() {
logger.Debug("CanAccessDelegatedAdmin: not a HouseLeader", "userId", u.ID)
return false
}
// Check if global delegation is enabled
settings, err := GetDelegationSettings()
if err != nil || settings == nil {
logger.Debug("CanAccessDelegatedAdmin: failed to get settings", "error", err)
return false
}
var globalDelegationEnabled bool
switch delegation {
case DelegationUsers:
globalDelegationEnabled = settings.DelegateUsers
case DelegationChildren:
globalDelegationEnabled = settings.DelegateChildren
case DelegationGroups:
globalDelegationEnabled = settings.DelegateGroups
case DelegationEnrollments:
globalDelegationEnabled = settings.DelegateEnrollments
default:
return false
}
if !globalDelegationEnabled {
logger.Debug("CanAccessDelegatedAdmin: global delegation not enabled", "delegation", delegation)
return false
}
// Get locations the user leads (with full location data for per-location checks)
var leadLocations []Location
if err := db.Where("lead_id = ? OR lead2nd_id = ?", u.ID, u.ID).Find(&leadLocations).Error; err != nil {
logger.Debug("CanAccessDelegatedAdmin: error querying locations", "error", err)
return false
}
if len(leadLocations) == 0 {
logger.Debug("CanAccessDelegatedAdmin: user leads no locations", "userId", u.ID)
return false
}
logger.Debug("CanAccessDelegatedAdmin: found lead locations", "userId", u.ID, "count", len(leadLocations))
// Helper to check if a location has delegation enabled for this type
locationHasDelegation := func(loc Location) bool {
switch delegation {
case DelegationUsers:
return loc.DelegateUsers
case DelegationChildren:
return loc.DelegateChildren
case DelegationGroups:
return loc.DelegateGroups
case DelegationEnrollments:
return loc.DelegateEnrollments
}
return false
}
// If no specific location requested, check if user leads any location with delegation enabled
if locationID == nil {
for _, loc := range leadLocations {
if locationHasDelegation(loc) {
return true
}
}
return false
}
// Check if user leads the specified location AND that location has delegation enabled
for _, loc := range leadLocations {
if loc.ID == *locationID && locationHasDelegation(loc) {
return true
}
}
return false
}
// GetLeadLocationIDs returns location IDs where the user is Lead or Lead2nd
func (u User) GetLeadLocationIDs(db *gorm.DB) ([]uint, error) {
var locations []Location
err := db.Where("lead_id = ? OR lead2nd_id = ?", u.ID, u.ID).Find(&locations).Error
if err != nil {
return nil, err
}
locationIDs := make([]uint, len(locations))
for i, loc := range locations {
locationIDs[i] = loc.ID
}
return locationIDs, nil
}
// EffectiveRole represents the user's effective role at a specific location
// This may be downgraded from their actual role if they're working as a substitute
type EffectiveRole struct {
Role string // The effective role name: "Employee", "GroupLead", "LocationLead", "Admin"
IsSubstitute bool // True if the user is working as a substitute at this location
IsCoreTeam bool // True if the user is core team at this location
}
// GetEffectiveRoleForLocation returns the user's effective role for a specific location.
// Substitutes (IsCoreTeam=false) are downgraded to basic Employee regardless of their actual role.
// This ensures that a GroupLead working as a substitute at another location only has Employee rights there.
//
// Logic:
// - Admin: Always returns Admin (no downgrade)
// - If user has ANY IsCoreTeam=true assignment at this location: returns their actual role
// - If user ONLY has IsCoreTeam=false assignments at this location: returns "Employee"
// - If user has no daily assignments at this location: falls back to their actual role (manual assignments)
func (u User) GetEffectiveRoleForLocation(db *gorm.DB, locationID uint) EffectiveRole {
// Admin is never downgraded
if u.IsAdmin() {
return EffectiveRole{Role: "Admin", IsSubstitute: false, IsCoreTeam: true}
}
// Check if intranet sync is relevant for this user
if u.ExternalID == nil || *u.ExternalID == "" {
// No intranet sync - use actual role
return EffectiveRole{Role: u.getHighestRole(), IsSubstitute: false, IsCoreTeam: true}
}
// Get today's daily group assignments for this location
today := time.Now().Truncate(24 * time.Hour)
var dailyGroups []EmployeeDailyGroup
err := db.Joins("JOIN groups ON groups.id = employee_daily_groups.group_id").
Where("employee_daily_groups.user_id = ?", u.ID).
Where("employee_daily_groups.assignment_date = ?", today).
Where("groups.location_id = ?", locationID).
Find(&dailyGroups).Error
if err != nil || len(dailyGroups) == 0 {
// No daily assignments at this location - use actual role
// This handles manually assigned employees (via group_teachers) who don't have intranet sync
return EffectiveRole{Role: u.getHighestRole(), IsSubstitute: false, IsCoreTeam: true}
}
// Check if user is core team at ANY group in this location
hasCoreTeam := false
for _, dg := range dailyGroups {
if dg.IsCoreTeam {
hasCoreTeam = true
break
}
}
if hasCoreTeam {
// Core team at this location - use actual role
return EffectiveRole{Role: u.getHighestRole(), IsSubstitute: false, IsCoreTeam: true}
}
// Only substitute assignments at this location - downgrade to Employee
return EffectiveRole{Role: "Employee", IsSubstitute: true, IsCoreTeam: false}
}
// GetEffectiveRoleForGroup returns the user's effective role for a specific group.
// This is a convenience wrapper that looks up the group's location.
func (u User) GetEffectiveRoleForGroup(db *gorm.DB, groupID uint) EffectiveRole {
// Admin is never downgraded
if u.IsAdmin() {
return EffectiveRole{Role: "Admin", IsSubstitute: false, IsCoreTeam: true}
}
// Get the group's location
var group Group
if err := db.Select("location_id").First(&group, groupID).Error; err != nil {
// Can't find group - use actual role
return EffectiveRole{Role: u.getHighestRole(), IsSubstitute: false, IsCoreTeam: true}
}
return u.GetEffectiveRoleForLocation(db, group.LocationId)
}
// getHighestRole returns the user's highest role name
func (u User) getHighestRole() string {
if u.IsAdmin() {
return "Admin"
}
if u.IsHouseLeader() {
return "LocationLead"
}
if u.IsGroupLeader() {
return "GroupLead"
}
if u.IsEmployee() {
return "Employee"
}
if u.IsParent() {
return "Parent"
}
return "Anonymous"
}
// IsEffectiveGroupLeaderAt checks if user has GroupLead privileges at a specific location
// Returns false if user is working as a substitute there
func (u User) IsEffectiveGroupLeaderAt(db *gorm.DB, locationID uint) bool {
if !u.IsGroupLeader() && !u.IsHouseLeader() && !u.IsAdmin() {
return false
}
effective := u.GetEffectiveRoleForLocation(db, locationID)
return effective.Role == "GroupLead" || effective.Role == "LocationLead" || effective.Role == "Admin"
}
// IsEffectiveHouseLeaderAt checks if user has LocationLead privileges at a specific location
// Returns false if user is working as a substitute there
func (u User) IsEffectiveHouseLeaderAt(db *gorm.DB, locationID uint) bool {
if !u.IsHouseLeader() && !u.IsAdmin() {
return false
}
effective := u.GetEffectiveRoleForLocation(db, locationID)
return effective.Role == "LocationLead" || effective.Role == "Admin"
}
package route
// Route definitions for the Wippidu Kita App
// Implements dual HTML/JSON API architecture
//
// [impl->dsn~dual-api-architektur~1]
import (
"net/http"
"wippidu_app_backend/internal/controller"
"wippidu_app_backend/internal/middleware"
"github.com/gin-gonic/gin"
)
func AuthRoutes(r *gin.Engine) {
// Health endpoints — anonymous, no language prefix. Wired at the
// top of the route table so they're visible without scrolling and
// to make it clear they bypass the auth/CSRF/language stack by
// design. Audit #464: required for /healthz, /readyz so the
// container orchestrator can probe liveness + readiness.
r.GET("/healthz", controller.Healthz)
r.GET("/readyz", controller.Readyz)
// Login route WITHOUT language prefix
r.GET("/login", controller.Login)
r.POST("/login", middleware.LoginRateLimit(), controller.Login)
// Language-prefixed login routes (redirect to /login)
// Many controllers redirect to /{lang}/login when session expires
r.GET("/de/login", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
})
r.GET("/en/login", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
})
// Public registration routes (no auth required)
// POST /register has rate limiting to prevent bot attacks
r.GET("/register", controller.ShowRegistrationForm)
r.POST("/register", middleware.RegistrationRateLimit(), controller.SubmitRegistration)
r.GET("/register/success", controller.RegistrationSuccess)
r.GET("/activate/:code", controller.ShowActivationForm)
r.POST("/activate/:code", controller.ActivateAccount)
// API endpoint for groups dropdown (public, used by registration form)
r.GET("/api/locations/:id/groups", controller.GetGroupsByLocation)
// API endpoint for invitation code validation (public, used by registration form)
// Rate-limited to slow down enumeration of invitation codes.
r.GET("/api/invitation/:code", middleware.InvitationLookupRateLimit(), controller.GetInvitationInfo)
// API endpoint for employee invitation code validation (public, used by registration form)
r.GET("/api/employee-invitation/:code", middleware.InvitationLookupRateLimit(), controller.GetEmployeeInvitationInfo)
// Public legal pages (no auth required)
r.GET("/impressum", controller.ShowImpressum)
r.GET("/datenschutz", controller.ShowDatenschutz)
// Root redirect to default language
r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/de/")
})
}
func MainRoutes(r *gin.Engine) {
// Language change endpoint (no language prefix needed)
r.POST("/change-language", controller.ChangeLanguage)
// Role change endpoint for dual-role users (no language prefix needed)
r.POST("/change-role", controller.ChangeRole)
// Stop impersonation endpoint (no language prefix needed)
r.POST("/stop-impersonation", controller.StopImpersonation)
// Language-prefixed routes for German
setupLanguageRoutes(r, "de")
// Language-prefixed routes for English
setupLanguageRoutes(r, "en")
}
// setupLanguageRoutes creates routes for a specific language
func setupLanguageRoutes(r *gin.Engine, lang string) {
group := r.Group("/" + lang)
{
group.GET("/logout", controller.Logout)
// Pending approval page (accessible to unapproved users)
group.GET("/pending-approval", controller.PendingApprovalPage)
// User Settings (password change is allowed for unapproved users)
group.GET("/settings", middleware.RequireApproval(), controller.ShowUserSettings)
group.GET("/settings/password", controller.ShowPasswordChangeForm)
group.POST("/settings/password", middleware.PasswordChangeRateLimit(), controller.ChangePassword)
group.GET("/settings/email", middleware.RequireApproval(), controller.ShowEmailChangeForm)
group.POST("/settings/email", middleware.RequireApproval(), controller.RequestEmailChange)
group.POST("/settings/email/cancel", middleware.RequireApproval(), controller.CancelEmailChange)
group.GET("/settings/email/confirm/:code", controller.ConfirmEmailChange)
group.POST("/settings/notifications", middleware.RequireApproval(), controller.UpdateNotificationSettings)
group.POST("/settings/contact", middleware.RequireApproval(), controller.UpdateContactDetails)
group.POST("/settings/stand-in/add", middleware.RequireApproval(), controller.CreateStandIn)
group.POST("/settings/stand-in/:id/revoke", middleware.RequireApproval(), controller.RevokeStandIn)
group.GET("/settings/refresh-permissions", middleware.RequireApproval(), controller.SettingsRefreshPermissions)
group.POST("/settings/refresh-permissions", middleware.RequireApproval(), controller.SettingsRefreshPermissionsPost)
// Public legal pages (with language prefix)
group.GET("/impressum", controller.ShowImpressum)
group.GET("/datenschutz", controller.ShowDatenschutz)
// FAQ page (requires login, shows role-appropriate content)
group.GET("/faq", middleware.RequireApproval(), controller.ShowFAQ)
// Changelog page (requires login)
group.GET("/changelog", middleware.RequireApproval(), controller.ChangelogView)
// Main, home, Children (require approval)
group.GET("/", middleware.RequireApproval(), controller.Home)
group.GET("/home", middleware.RequireApproval(), controller.Home)
group.POST("/", middleware.RequireApproval(), controller.Home)
// Child details
group.GET("/child/:id", middleware.RequireApproval(), controller.Child)
// Utility routes
group.POST("/preview-markdown", middleware.RequireApproval(), controller.PreviewMarkdown)
// General routes
group.GET("/news", middleware.RequireApproval(), controller.News)
group.GET("/news/:id", middleware.RequireApproval(), controller.GetNewsDetail)
group.GET("/messages", middleware.RequireApproval(), controller.ParentMessages)
group.GET("/messages/send", middleware.RequireApproval(), controller.SendMessageForm)
group.GET("/messages/send/:childid", middleware.RequireApproval(), controller.SendMessageForm)
group.POST("/messages/send", middleware.RequireApproval(), controller.SendParentMessage)
group.GET("/messages/batch/:batchid", middleware.RequireApproval(), controller.ViewBatchDetail)
group.GET("/messages/edit/:id", middleware.RequireApproval(), controller.EditMessageForm)
group.POST("/messages/update/:id", middleware.RequireApproval(), controller.UpdateParentMessage)
group.GET("/parent/messages/:id", middleware.RequireApproval(), controller.ParentViewMessageDetail)
group.POST("/parent/messages/:id/answer", middleware.RequireApproval(), controller.ParentSubmitAnswer)
group.GET("/documents", middleware.RequireApproval(), controller.NotImplemented("Documents"))
// Parent routes
group.GET("/notify", middleware.RequireApproval(), controller.Notify)
group.GET("/notify/:id", middleware.RequireApproval(), controller.NotifyDirect)
group.POST("/notifysend", middleware.RequireApproval(), controller.NotifySend)
group.GET("/notify-history", middleware.RequireApproval(), controller.NotifyHistory)
group.GET("/notify-edit/:id", middleware.RequireApproval(), controller.NotifyEdit)
group.POST("/notify-update/:id", middleware.RequireApproval(), controller.NotifyUpdate)
group.POST("/notify-revoke/:id", middleware.RequireApproval(), controller.NotifyRevoke)
group.GET("/parentalletters", middleware.RequireApproval(), controller.ParentalLetters)
group.GET("/parentalletters/:id", middleware.RequireApproval(), controller.ViewLetterParent)
group.POST("/parentalletters/:id/answer", middleware.RequireApproval(), controller.ParentSubmitLetterAnswer)
group.POST("/parentalletters/:id/vote", middleware.RequireApproval(), controller.ParentSubmitPollVote)
group.POST("/parentalletters/:id/table-cell", middleware.RequireApproval(), controller.ParentSubmitTableCell)
group.POST("/parentalletters/:id/table-cells", middleware.RequireApproval(), controller.ParentSubmitTableCells)
group.POST("/parentalletters/:id/table-entry", middleware.RequireApproval(), controller.ParentAddTableEntry)
group.POST("/parentalletters/:id/table-entry/:rowId", middleware.RequireApproval(), controller.ParentUpdateTableEntry)
group.POST("/parentalletters/:id/table-entry/:rowId/delete", middleware.RequireApproval(), controller.ParentDeleteTableEntry)
group.POST("/parentalletters/:id/survey", middleware.RequireApproval(), controller.ParentSubmitSurvey)
group.GET("/caredays", middleware.RequireApproval(), controller.NotImplemented("Care Days"))
// Employee routes (require approval)
group.GET("/employee/child/:id", middleware.RequireApproval(), controller.EmployeeChildDetail)
group.POST("/employee/child/:id/notes", middleware.RequireApproval(), controller.SaveChildNotes)
group.GET("/employee/location-children", middleware.RequireApproval(), controller.LocationChildren)
group.GET("/notifications", middleware.RequireApproval(), controller.Notifications)
group.POST("/notifications/acknowledge-all", middleware.RequireApproval(), controller.AcknowledgeAllNotifications)
group.POST("/notifications/:id/acknowledge", middleware.RequireApproval(), controller.AcknowledgeNotification)
group.GET("/information", middleware.RequireApproval(), controller.NotImplemented("Information Board"))
// Archive routes (Employee/Admin only)
group.GET("/archive", middleware.RequireApproval(), controller.Archive)
group.GET("/archive/load-more", middleware.RequireApproval(), controller.ArchiveLoadMore)
// Employee Chat routes (require approval)
group.GET("/chat", middleware.RequireApproval(), controller.EmployeeChatList)
group.GET("/chat/new", middleware.RequireApproval(), controller.EmployeeChatCreateForm)
group.POST("/chat/create", middleware.RequireApproval(), controller.EmployeeChatCreate)
group.GET("/chat/:id", middleware.RequireApproval(), controller.EmployeeChatView)
group.GET("/chat/:id/messages", middleware.RequireApproval(), controller.EmployeeChatMessages)
group.POST("/chat/:id/send", middleware.RequireApproval(), controller.EmployeeChatSend)
// Invitation code generation (Employee/GroupLead/LocationLead)
group.GET("/employee/child/:id/invite", middleware.RequireApproval(), controller.ShowGenerateInvitationForm)
group.POST("/employee/child/:id/invite", middleware.RequireApproval(), controller.GenerateInvitationCode)
group.POST("/employee/child/:id/invite/:codeid/revoke", middleware.RequireApproval(), controller.RevokeInvitationCode)
// Bulk invitation code generation (GroupLead/LocationLead/Admin)
group.GET("/invitations/bulk", middleware.RequireApproval(), controller.ShowBulkInvitationPage)
group.POST("/invitations/bulk", middleware.RequireApproval(), controller.GenerateBulkInvitations)
group.GET("/invitations/print", middleware.RequireApproval(), controller.ShowBulkPrintPage)
// Group Leader routes (require approval)
group.GET("/parental-letters", middleware.RequireApproval(), controller.ParentalLetters)
group.GET("/parental-letters/new", middleware.RequireApproval(), controller.CreateLetterForm)
group.POST("/parental-letters/create", middleware.RequireApproval(), controller.CreateLetter)
group.GET("/parental-letters/:id/edit", middleware.RequireApproval(), controller.EditLetterForm)
group.POST("/parental-letters/:id/update", middleware.RequireApproval(), controller.UpdateLetter)
group.POST("/parental-letters/:id/update/delete", middleware.RequireApproval(), controller.DeleteLetter)
group.GET("/parental-letters/:id/review", middleware.RequireApproval(), controller.ReviewLetterForm)
group.POST("/parental-letters/:id/review", middleware.RequireApproval(), controller.ReviewLetter)
group.POST("/parental-letters/:id/answer", middleware.RequireApproval(), controller.ParentSubmitLetterAnswer)
group.GET("/parental-letters/:id", middleware.RequireApproval(), controller.ViewLetterEmployee)
// Attachment routes for parental letters
group.GET("/attachments/:id", middleware.RequireApproval(), controller.ServeAttachment)
group.DELETE("/attachments/:id", middleware.RequireApproval(), controller.DeleteAttachment)
group.POST("/attachments/:id/delete", middleware.RequireApproval(), controller.DeleteAttachment)
group.POST("/attachments/upload", middleware.RequireApproval(), controller.UploadAttachment)
// News routes (Group Leader can create for their groups)
group.GET("/news/new", middleware.RequireApproval(), controller.ShowCreateNews)
group.POST("/news/create", middleware.RequireApproval(), controller.CreateNews)
group.GET("/news/:id/edit", middleware.RequireApproval(), controller.ShowEditNews)
group.POST("/news/:id/update", middleware.RequireApproval(), controller.UpdateNews)
group.POST("/news/:id/delete", middleware.RequireApproval(), controller.DeleteNews)
// News attachment routes
group.GET("/news-attachments/:id", middleware.RequireApproval(), controller.ServeNewsAttachment)
group.DELETE("/news-attachments/:id", middleware.RequireApproval(), controller.DeleteNewsAttachment)
group.POST("/news-attachments/:id/delete", middleware.RequireApproval(), controller.DeleteNewsAttachment)
group.POST("/news-attachments/upload", middleware.RequireApproval(), controller.UploadNewsAttachment)
// Calendar routes (all approved users can view, Group/Location Lead and Admin can create/edit)
group.GET("/calendar", middleware.RequireApproval(), controller.Calendar)
group.GET("/calendar/event/new", middleware.RequireApproval(), controller.ShowCreateCalendarEvent)
group.POST("/calendar/event/create", middleware.RequireApproval(), controller.CreateCalendarEvent)
group.GET("/calendar/event/:id", middleware.RequireApproval(), controller.GetCalendarEventDetail)
group.GET("/calendar/event/:id/edit", middleware.RequireApproval(), controller.ShowEditCalendarEvent)
group.POST("/calendar/event/:id/update", middleware.RequireApproval(), controller.UpdateCalendarEvent)
group.POST("/calendar/event/:id/post-news", middleware.RequireApproval(), controller.PostNewsForEvent)
group.POST("/calendar/event/:id/cancel", middleware.RequireApproval(), controller.CancelCalendarEvent)
group.POST("/calendar/event/:id/delete", middleware.RequireApproval(), controller.DeleteCalendarEvent)
// Child Cluster Management (GroupLead/LocationLead)
group.GET("/clusters", middleware.RequireApproval(), controller.ListChildClusters)
group.GET("/clusters/new", middleware.RequireApproval(), controller.ShowCreateClusterForm)
group.POST("/clusters/create", middleware.RequireApproval(), controller.CreateChildCluster)
group.GET("/clusters/:id/edit", middleware.RequireApproval(), controller.ShowEditClusterForm)
group.POST("/clusters/:id/update", middleware.RequireApproval(), controller.UpdateChildCluster)
group.POST("/clusters/:id/delete", middleware.RequireApproval(), controller.DeleteChildCluster)
group.GET("/api/clusters/children", middleware.RequireApproval(), controller.GetChildrenForGroup)
group.GET("/document-management", middleware.RequireApproval(), controller.NotImplemented("Document Management"))
group.GET("/information-management", middleware.RequireApproval(), controller.NotImplemented("Information Board Management"))
// House Leader routes (require approval)
group.GET("/global-parental-letters", middleware.RequireApproval(), controller.ParentalLetters)
group.GET("/global-parental-letters/new", middleware.RequireApproval(), controller.CreateLetterForm)
group.POST("/global-parental-letters/create", middleware.RequireApproval(), controller.CreateLetter)
group.GET("/global-parental-letters/:id/edit", middleware.RequireApproval(), controller.EditLetterForm)
group.POST("/global-parental-letters/:id/update", middleware.RequireApproval(), controller.UpdateLetter)
group.POST("/global-parental-letters/:id/update/delete", middleware.RequireApproval(), controller.DeleteLetter)
group.GET("/global-parental-letters/:id/review", middleware.RequireApproval(), controller.ReviewLetterForm)
group.POST("/global-parental-letters/:id/review", middleware.RequireApproval(), controller.ReviewLetter)
group.POST("/global-parental-letters/:id/answer", middleware.RequireApproval(), controller.ParentSubmitLetterAnswer)
group.GET("/global-parental-letters/:id", middleware.RequireApproval(), controller.ViewLetterEmployee)
group.GET("/PAC", middleware.RequireApproval(), controller.NotImplemented("Parent Access Control"))
// Location Lead - Parent Access Time Management (require approval)
group.GET("/location/access-times", middleware.RequireApproval(), controller.LocationAccessTimesList)
group.GET("/location/access-times/:userid/:childid/edit", middleware.RequireApproval(), controller.LocationShowEditAccessTime)
group.POST("/location/access-times/:userid/:childid/update", middleware.RequireApproval(), controller.LocationUpdateAccessTime)
// Location Lead - Location Settings (require approval)
group.GET("/location/settings", middleware.RequireApproval(), controller.LocationSettings)
group.POST("/location/settings", middleware.RequireApproval(), controller.LocationSettingsSave)
group.POST("/location/holidays/import", middleware.RequireApproval(), controller.LocationHolidayImport)
// Admin routes (require approval)
group.GET("/admin-change-group", middleware.RequireApproval(), controller.NotImplemented("Change House/Group"))
group.GET("/RBAC", middleware.RequireApproval(), controller.NotImplemented("Role Management"))
group.POST("/admin/set-location", middleware.RequireApproval(), controller.SetAdminLocation)
// Admin News CRUD (require approval)
group.GET("/admin/news", middleware.RequireApproval(), controller.AdminNewsList)
group.GET("/admin/news/new", middleware.RequireApproval(), controller.AdminShowCreateNews)
group.POST("/admin/news/create", middleware.RequireApproval(), controller.AdminCreateNews)
group.GET("/admin/news/:id", middleware.RequireApproval(), controller.AdminNewsDetail)
group.GET("/admin/news/:id/edit", middleware.RequireApproval(), controller.AdminShowEditNews)
group.POST("/admin/news/:id/update", middleware.RequireApproval(), controller.AdminUpdateNews)
group.POST("/admin/news/:id/delete", middleware.RequireApproval(), controller.AdminDeleteNews)
// Admin User Management (require approval)
group.GET("/admin/users", middleware.RequireApproval(), controller.AdminUsersList)
group.GET("/admin/users/new", middleware.RequireApproval(), controller.AdminShowCreateUser)
group.POST("/admin/users/create", middleware.RequireApproval(), controller.AdminCreateUser)
group.GET("/admin/users/:id", middleware.RequireApproval(), controller.AdminUserDetail)
group.GET("/admin/users/:id/edit", middleware.RequireApproval(), controller.AdminShowEditUser)
group.POST("/admin/users/:id/update", middleware.RequireApproval(), controller.AdminUpdateUser)
// Admin User-Child Relationship Management (require approval)
group.GET("/admin/users/:id/children/add", middleware.RequireApproval(), controller.AdminShowAddChild)
group.POST("/admin/users/:id/children/add", middleware.RequireApproval(), controller.AdminAddChild)
group.GET("/admin/users/:id/children/:childid/edit", middleware.RequireApproval(), controller.AdminShowEditChildRelation)
group.POST("/admin/users/:id/children/:childid/update", middleware.RequireApproval(), controller.AdminUpdateChildRelation)
group.POST("/admin/users/:id/children/:childid/delete", middleware.RequireApproval(), controller.AdminDeleteChildRelation)
// Admin User-Group Management (Employee Group Assignment) - require approval
group.GET("/admin/users/:id/groups/add", middleware.RequireApproval(), controller.AdminShowAddGroup)
group.POST("/admin/users/:id/groups/add", middleware.RequireApproval(), controller.AdminAddGroup)
group.POST("/admin/users/:id/groups/:groupid/delete", middleware.RequireApproval(), controller.AdminDeleteGroup)
// Admin Child Management (require approval)
group.GET("/admin/children", middleware.RequireApproval(), controller.AdminChildrenList)
group.GET("/admin/children/new", middleware.RequireApproval(), controller.AdminShowCreateChild)
group.POST("/admin/children/create", middleware.RequireApproval(), controller.AdminCreateChild)
group.GET("/admin/children/:id", middleware.RequireApproval(), controller.AdminChildDetail)
group.GET("/admin/children/:id/edit", middleware.RequireApproval(), controller.AdminShowEditChild)
group.POST("/admin/children/:id/update", middleware.RequireApproval(), controller.AdminUpdateChild)
// Admin Child-Parent Relationship Management (require approval)
group.GET("/admin/children/:id/parents/add", middleware.RequireApproval(), controller.AdminShowAddParent)
group.POST("/admin/children/:id/parents/add", middleware.RequireApproval(), controller.AdminAddParent)
group.GET("/admin/children/:id/parents/:userid/edit", middleware.RequireApproval(), controller.AdminShowEditParentRelation)
group.POST("/admin/children/:id/parents/:userid/update", middleware.RequireApproval(), controller.AdminUpdateParentRelation)
group.POST("/admin/children/:id/parents/:userid/delete", middleware.RequireApproval(), controller.AdminDeleteParentRelation)
// Admin Impersonation (require approval)
group.POST("/admin/impersonate/:userid", middleware.RequireApproval(), controller.StartImpersonation)
// Admin API Token Management (require approval)
group.GET("/admin/tokens", middleware.RequireApproval(), controller.AdminTokensList)
group.GET("/admin/tokens/new", middleware.RequireApproval(), controller.AdminShowCreateToken)
group.POST("/admin/tokens/create", middleware.RequireApproval(), controller.AdminCreateToken)
group.GET("/admin/tokens/:id/edit", middleware.RequireApproval(), controller.AdminShowEditToken)
group.POST("/admin/tokens/:id/update", middleware.RequireApproval(), controller.AdminUpdateToken)
group.POST("/admin/tokens/:id/revoke", middleware.RequireApproval(), controller.AdminRevokeToken)
group.POST("/admin/tokens/:id/regenerate", middleware.RequireApproval(), controller.AdminRegenerateToken)
// Admin Sync Processing (require approval)
group.GET("/admin/sync", middleware.RequireApproval(), controller.AdminSyncDashboard)
group.POST("/admin/sync/all", middleware.RequireApproval(), controller.AdminProcessAll)
group.POST("/admin/sync/locations", middleware.RequireApproval(), controller.AdminProcessLocations)
group.POST("/admin/sync/groups", middleware.RequireApproval(), controller.AdminProcessGroups)
group.POST("/admin/sync/children", middleware.RequireApproval(), controller.AdminProcessChildren)
group.POST("/admin/sync/childgroups", middleware.RequireApproval(), controller.AdminProcessChildGroups)
group.POST("/admin/sync/groupleads", middleware.RequireApproval(), controller.AdminProcessGroupLeads)
group.POST("/admin/sync/locationleads", middleware.RequireApproval(), controller.AdminProcessLocationLeads)
group.POST("/admin/sync/autosync", middleware.RequireApproval(), controller.AdminToggleAutoSync)
// Admin Enrollment Management (require approval)
group.GET("/admin/enrollments", middleware.RequireApproval(), controller.AdminEnrollmentsList)
group.GET("/admin/enrollments/new", middleware.RequireApproval(), controller.AdminShowCreateEnrollment)
group.POST("/admin/enrollments/create", middleware.RequireApproval(), controller.AdminCreateEnrollment)
group.GET("/admin/enrollments/:id/edit", middleware.RequireApproval(), controller.AdminShowEditEnrollment)
group.POST("/admin/enrollments/:id/update", middleware.RequireApproval(), controller.AdminUpdateEnrollment)
group.POST("/admin/enrollments/:id/delete", middleware.RequireApproval(), controller.AdminDeleteEnrollment)
// Admin Group Management (require approval)
group.GET("/admin/groups", middleware.RequireApproval(), controller.AdminGroupsList)
group.GET("/admin/groups/new", middleware.RequireApproval(), controller.AdminShowCreateGroup)
group.POST("/admin/groups/create", middleware.RequireApproval(), controller.AdminCreateGroup)
group.GET("/admin/groups/:id/edit", middleware.RequireApproval(), controller.AdminShowEditGroup)
group.POST("/admin/groups/:id/update", middleware.RequireApproval(), controller.AdminUpdateGroup)
group.POST("/admin/groups/:id/delete", middleware.RequireApproval(), controller.AdminGroupsDelete)
// Admin Location Management (require approval)
group.GET("/admin/locations", middleware.RequireApproval(), controller.AdminLocationsList)
group.GET("/admin/locations/new", middleware.RequireApproval(), controller.AdminShowCreateLocation)
group.POST("/admin/locations/create", middleware.RequireApproval(), controller.AdminCreateLocation)
group.GET("/admin/locations/:id/edit", middleware.RequireApproval(), controller.AdminShowEditLocation)
group.POST("/admin/locations/:id/update", middleware.RequireApproval(), controller.AdminUpdateLocation)
group.POST("/admin/locations/:id/delete", middleware.RequireApproval(), controller.AdminDeleteLocation)
// Registration Management (Admin, LocationLead, GroupLead) - require approval
group.GET("/admin/registrations", middleware.RequireApproval(), controller.AdminRegistrationsList)
group.GET("/admin/registrations/:id", middleware.RequireApproval(), controller.AdminRegistrationDetail)
group.POST("/admin/registrations/:id/approve", middleware.RequireApproval(), controller.AdminApproveRegistration)
group.POST("/admin/registrations/:id/reject", middleware.RequireApproval(), controller.AdminRejectRegistration)
// Employee Invitation Code Generation (Admin only) - require approval
group.GET("/admin/employee-invitations", middleware.RequireApproval(), controller.ShowBulkEmployeeInvitationPage)
group.POST("/admin/employee-invitations", middleware.RequireApproval(), controller.GenerateBulkEmployeeInvitations)
group.GET("/admin/employee-invitations/print", middleware.RequireApproval(), controller.ShowBulkEmployeePrintPage)
// Admin Import/Export (full database) — import side gated by APP_ALLOW_PROD_IMPORT
group.GET("/admin/import-export", middleware.RequireApproval(), controller.AdminImportExport)
group.POST("/admin/import-export/export", middleware.RequireApproval(), controller.AdminImportExportExport)
group.POST("/admin/import-export/import", middleware.RequireApproval(), controller.AdminImportExportImport)
// Admin Email Settings (require approval)
group.GET("/admin/email-settings", middleware.RequireApproval(), controller.AdminEmailSettings)
group.POST("/admin/email-settings", middleware.RequireApproval(), controller.AdminSaveEmailSettings)
group.POST("/admin/email-settings/test", middleware.RequireApproval(), controller.AdminTestEmailSettings)
// Admin Legal Settings (Impressum, Datenschutz) - require approval
group.GET("/admin/legal-settings", middleware.RequireApproval(), controller.AdminLegalSettings)
group.POST("/admin/legal-settings", middleware.RequireApproval(), controller.AdminSaveLegalSettings)
group.POST("/admin/legal-settings/preview", middleware.RequireApproval(), controller.AdminPreviewMarkdown)
// Admin FAQ Settings - require approval
group.GET("/admin/faq-settings", middleware.RequireApproval(), controller.AdminFAQSettings)
group.POST("/admin/faq-settings", middleware.RequireApproval(), controller.AdminSaveFAQSettings)
group.POST("/admin/faq-settings/preview", middleware.RequireApproval(), controller.AdminPreviewFAQMarkdown)
// Admin Changelog Management - require approval
group.GET("/admin/changelog", middleware.RequireApproval(), controller.AdminChangelogList)
group.GET("/admin/changelog/new", middleware.RequireApproval(), controller.AdminChangelogCreate)
group.POST("/admin/changelog/new", middleware.RequireApproval(), controller.AdminChangelogSave)
group.GET("/admin/changelog/:id/edit", middleware.RequireApproval(), controller.AdminChangelogEdit)
group.POST("/admin/changelog/:id/update", middleware.RequireApproval(), controller.AdminChangelogUpdate)
group.POST("/admin/changelog/:id/delete", middleware.RequireApproval(), controller.AdminChangelogDelete)
// Admin Session Settings (require approval)
group.GET("/admin/session-settings", middleware.RequireApproval(), controller.AdminSessionSettings)
group.POST("/admin/session-settings", middleware.RequireApproval(), controller.AdminSaveSessionSettings)
// Admin Delegation Settings (require approval)
group.GET("/admin/delegation", middleware.RequireApproval(), controller.AdminDelegationSettings)
group.POST("/admin/delegation/update", middleware.RequireApproval(), controller.AdminUpdateDelegationSettings)
// Admin overview of active stand-ins (Stellvertretung)
group.GET("/admin/stand-ins", middleware.RequireApproval(), controller.AdminListStandIns)
// Admin Intranet Settings (require approval)
group.GET("/admin/intranet-settings", middleware.RequireApproval(), controller.AdminIntranetSettings)
group.POST("/admin/intranet-settings", middleware.RequireApproval(), controller.AdminIntranetSettingsSave)
group.POST("/admin/intranet-settings/test", middleware.RequireApproval(), controller.AdminIntranetSettingsTest)
// Admin Holiday Settings (require approval)
group.GET("/admin/holiday-settings", middleware.RequireApproval(), controller.AdminHolidaySettings)
group.POST("/admin/holiday-settings", middleware.RequireApproval(), controller.AdminHolidaySettingsSave)
// Feedback routes (require approval)
group.GET("/feedback", middleware.RequireApproval(), controller.ShowFeedbackForm)
group.POST("/feedback", middleware.RequireApproval(), controller.SubmitFeedback)
}
}
// APIRoutes defines JSON API endpoints for JavaScript frontend and mobile apps
// All routes under /api/v1/ will return JSON responses.
//
// Authentication for /api/v1/* is enforced at the router level via
// middleware.RequireAuth(). Audit #464 flagged that the previous
// arrangement — relying solely on the global IsAuthorized() in
// main.go — would silently expose every JSON endpoint if that one
// line were ever removed or reordered, because IsAuthorized() is
// permissive (it sets User on success but does not abort on missing
// credentials). RequireAuth fails closed and is visible here in the
// route table.
//
// /api/v1/login is intentionally registered outside the authenticated
// group — it's how clients obtain a session in the first place.
func APIRoutes(r *gin.Engine) {
// Anonymous endpoint: login is how clients obtain a session.
r.POST("/api/v1/login", controller.Login)
api := r.Group("/api/v1", middleware.RequireAuth())
{
api.POST("/logout", controller.Logout)
// Language-prefixed API routes for German
setupAPILanguageRoutes(api, "de")
// Language-prefixed API routes for English
setupAPILanguageRoutes(api, "en")
}
}
// setupAPILanguageRoutes creates API routes for a specific language
func setupAPILanguageRoutes(apiGroup *gin.RouterGroup, lang string) {
langGroup := apiGroup.Group("/" + lang)
{
// Main endpoints
langGroup.GET("/home", controller.Home)
// Child endpoints
langGroup.GET("/child/:id", controller.Child)
// General endpoints
langGroup.GET("/news", controller.News)
langGroup.GET("/news/:id", controller.GetNewsDetail)
langGroup.GET("/messages", controller.ParentMessages)
langGroup.GET("/messages/send", controller.SendMessageForm)
langGroup.GET("/messages/send/:childid", controller.SendMessageForm)
langGroup.POST("/messages/send", controller.SendParentMessage)
langGroup.GET("/messages/batch/:batchid", controller.ViewBatchDetail)
langGroup.GET("/messages/edit/:id", controller.EditMessageForm)
langGroup.POST("/messages/update/:id", controller.UpdateParentMessage)
langGroup.GET("/parent/messages/:id", controller.ParentViewMessageDetail)
langGroup.POST("/parent/messages/:id/answer", controller.ParentSubmitAnswer)
langGroup.GET("/documents", controller.NotImplemented("Documents"))
// Parent endpoints
langGroup.GET("/notify", controller.Notify)
langGroup.GET("/notify/:id", controller.NotifyDirect)
langGroup.POST("/notifysend", controller.NotifySend)
langGroup.GET("/notify-history", controller.NotifyHistory)
langGroup.GET("/notify-edit/:id", controller.NotifyEdit)
langGroup.POST("/notify-update/:id", controller.NotifyUpdate)
langGroup.POST("/notify-revoke/:id", controller.NotifyRevoke)
langGroup.GET("/parentalletters", controller.NotImplemented("Parental Letters"))
langGroup.GET("/caredays", controller.NotImplemented("Care Days"))
// Employee endpoints
langGroup.GET("/employee/child/:id", controller.EmployeeChildDetail)
langGroup.GET("/employee/location-children", controller.LocationChildren)
langGroup.GET("/notifications", controller.Notifications)
langGroup.POST("/notifications/acknowledge-all", controller.AcknowledgeAllNotifications)
langGroup.POST("/notifications/:id/acknowledge", controller.AcknowledgeNotification)
langGroup.GET("/information", controller.NotImplemented("Information Board"))
// Employee Chat API endpoints
langGroup.GET("/chat", controller.EmployeeChatList)
langGroup.GET("/chat/:id", controller.EmployeeChatView)
langGroup.GET("/chat/:id/messages", controller.EmployeeChatMessages)
langGroup.POST("/chat/:id/send", controller.EmployeeChatSend)
// Group Leader endpoints
langGroup.GET("/parental-letters", controller.NotImplemented("Group Parental Letter"))
// News endpoints (Group Leader can create for their groups)
langGroup.GET("/news/new", controller.ShowCreateNews)
langGroup.POST("/news/create", controller.CreateNews)
langGroup.GET("/news/:id/edit", controller.ShowEditNews)
langGroup.POST("/news/:id/update", controller.UpdateNews)
langGroup.POST("/news/:id/delete", controller.DeleteNews)
// News attachment API endpoints
langGroup.GET("/news-attachments/:id", controller.ServeNewsAttachment)
langGroup.DELETE("/news-attachments/:id", controller.DeleteNewsAttachment)
langGroup.POST("/news-attachments/:id/delete", controller.DeleteNewsAttachment)
langGroup.POST("/news-attachments/upload", controller.UploadNewsAttachment)
langGroup.GET("/document-management", controller.NotImplemented("Document Management"))
langGroup.GET("/information-management", controller.NotImplemented("Information Board Management"))
// House Leader endpoints
langGroup.GET("/global-parental-letters", controller.NotImplemented("All Groups Letters"))
langGroup.GET("/PAC", controller.NotImplemented("Parent Access Control"))
// Location Lead - Parent Access Time Management
langGroup.GET("/location/access-times", controller.LocationAccessTimesList)
langGroup.GET("/location/access-times/:userid/:childid/edit", controller.LocationShowEditAccessTime)
langGroup.POST("/location/access-times/:userid/:childid/update", controller.LocationUpdateAccessTime)
// Location Lead - Location Settings
langGroup.GET("/location/settings", controller.LocationSettings)
langGroup.POST("/location/settings", controller.LocationSettingsSave)
// Admin endpoints
langGroup.GET("/admin-change-group", controller.NotImplemented("Change House/Group"))
langGroup.GET("/RBAC", controller.NotImplemented("Role Management"))
// Feedback endpoints
langGroup.GET("/feedback", controller.ShowFeedbackForm)
langGroup.POST("/feedback", controller.SubmitFeedback)
}
}
package route
// Intranet API routes - endpoints for receiving data from intranet system
//
// [impl->dsn~import-export-design~1]
import (
"wippidu_app_backend/internal/controller"
"wippidu_app_backend/internal/middleware"
"github.com/gin-gonic/gin"
)
// IntranetAPIRoutes sets up the intranet data sync API endpoints.
// These endpoints are used for receiving data from the Wippidu intranet system.
// Authentication is via Bearer token (not JWT) with optional IP allowlist.
func IntranetAPIRoutes(r *gin.Engine) {
intranet := r.Group("/api/intranet")
intranet.Use(middleware.IntranetAPIAuth())
{
// Person data (parents, children, employees)
intranet.POST("/person", controller.IntranetSyncPerson)
// Parent-child relationships
intranet.POST("/parent-child", controller.IntranetSyncParentChild)
// Group assignments (Belegungstabelle)
// Two endpoints for the two source tables in the intranet
intranet.POST("/belegung/krp", controller.IntranetSyncBelegungKrp)
intranet.POST("/belegung/ue3", controller.IntranetSyncBelegungUe3)
// Employee data
intranet.POST("/employee", controller.IntranetSyncEmployee)
// Organizational structure
intranet.POST("/location", controller.IntranetSyncLocation)
intranet.POST("/group", controller.IntranetSyncGroup)
// Leadership assignments
intranet.POST("/location-lead", controller.IntranetSyncLocationLead)
intranet.POST("/group-lead", controller.IntranetSyncGroupLead)
}
}
package service
import (
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// ArchiveTabCounts holds counts for each archive tab
type ArchiveTabCounts struct {
News int64
Letters int64
Messages int64
Absences int64
}
// ArchiveFilters holds filter parameters for archive queries
type ArchiveFilters struct {
Search string
DateFrom *time.Time
DateTo *time.Time
GroupID uint
BeforeID uint
Limit int
SortAsc bool // If true, sort oldest first; if false, sort newest first
}
// GetArchiveAccessibleGroups returns the group IDs that a user can view in the archive.
// For employees, this is based on their assigned groups and location access settings.
// For admins, this respects the admin location filter.
func GetArchiveAccessibleGroups(db *gorm.DB, user *model.User, adminLocationId uint) ([]uint, error) {
// Admin with "All locations" selected (adminLocationId == 0)
if user.IsAdmin() && adminLocationId == 0 {
return nil, nil // nil means no group filter (all groups)
}
// Admin with specific location selected
if user.IsAdmin() && adminLocationId > 0 {
var groupIDs []uint
err := db.Model(&model.Group{}).
Where("location_id = ?", adminLocationId).
Pluck("id", &groupIDs).Error
return groupIDs, err
}
// For employees (GroupLead, LocationLead, Employee)
// Get assigned groups for viewing (includes substitute groups for intranet employees)
assignedGroups, err := user.GetEmployeeGroups(db)
if err != nil {
return nil, err
}
groupIDsMap := make(map[uint]bool)
for _, g := range assignedGroups {
groupIDsMap[g.ID] = true
// Check if user has location-wide access based on EmployeeLocationAccess setting
if g.Location != nil && g.Location.CanEmployeeAccessAllChildren(user) {
// Add all groups at this location
var locationGroups []model.Group
if err := db.Where("location_id = ?", g.LocationId).Find(&locationGroups).Error; err == nil {
for _, lg := range locationGroups {
groupIDsMap[lg.ID] = true
}
}
}
}
// For LocationLeads, also include groups at locations where they are Lead or Lead2nd
if user.IsHouseLeader() {
var leadLocations []model.Location
err := db.Where("lead_id = ? OR lead2nd_id = ?", user.ID, user.ID).Find(&leadLocations).Error
if err == nil {
for _, loc := range leadLocations {
var locationGroups []model.Group
if err := db.Where("location_id = ?", loc.ID).Find(&locationGroups).Error; err == nil {
for _, lg := range locationGroups {
groupIDsMap[lg.ID] = true
}
}
}
}
// Check sync_location_leads for users with ExternalID
if user.ExternalID != nil && *user.ExternalID != "" {
var syncLocations []model.Location
err := db.Joins("JOIN sync_location_leads ON locations.external_id = sync_location_leads.einrichtungs_id").
Where("sync_location_leads.mitarbeiter_id = ?", *user.ExternalID).
Where("sync_location_leads.g_von IS NULL OR sync_location_leads.g_von <= NOW()").
Where("sync_location_leads.g_bis IS NULL OR sync_location_leads.g_bis >= NOW()").
Find(&syncLocations).Error
if err == nil {
for _, loc := range syncLocations {
var locationGroups []model.Group
if err := db.Where("location_id = ?", loc.ID).Find(&locationGroups).Error; err == nil {
for _, lg := range locationGroups {
groupIDsMap[lg.ID] = true
}
}
}
}
}
}
// Convert map to slice
groupIDs := make([]uint, 0, len(groupIDsMap))
for id := range groupIDsMap {
groupIDs = append(groupIDs, id)
}
return groupIDs, nil
}
// GetGroupsForArchiveFilter returns groups for the filter dropdown, scoped to user access.
func GetGroupsForArchiveFilter(db *gorm.DB, user *model.User, adminLocationId uint) ([]model.Group, error) {
groupIDs, err := GetArchiveAccessibleGroups(db, user, adminLocationId)
if err != nil {
return nil, err
}
var groups []model.Group
query := db.Preload("Location").Order("locations.name, groups.name")
if groupIDs != nil {
query = query.Where("groups.id IN ?", groupIDs)
}
err = query.Joins("JOIN locations ON groups.location_id = locations.id").Find(&groups).Error
return groups, err
}
// GetArchiveTabCounts returns counts per tab for the archive interface.
func GetArchiveTabCounts(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) (*ArchiveTabCounts, error) {
counts := &ArchiveTabCounts{}
// Count archived news
newsCount, err := countArchivedNews(db, groupIDs, filters)
if err != nil {
return nil, err
}
counts.News = newsCount
// Count archived letters
lettersCount, err := countArchivedLetters(db, groupIDs, filters)
if err != nil {
return nil, err
}
counts.Letters = lettersCount
// Count archived messages
messagesCount, err := countArchivedMessages(db, groupIDs, filters)
if err != nil {
return nil, err
}
counts.Messages = messagesCount
// Count archived absences
absencesCount, err := countArchivedAbsences(db, groupIDs, filters)
if err != nil {
return nil, err
}
counts.Absences = absencesCount
return counts, nil
}
func countArchivedNews(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) (int64, error) {
// Fetch candidates and count with Go-side retention filtering
var news []model.News
query := buildArchivedNewsQuery(db, groupIDs, filters).
Preload("Location").
Limit(1000) // Cap for performance
if err := query.Find(&news).Error; err != nil {
return 0, err
}
now := time.Now()
var count int64
for _, n := range news {
if isNewsArchived(n, now) {
count++
}
}
return count, nil
}
func countArchivedLetters(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) (int64, error) {
// Fetch candidates and count with Go-side retention filtering
var letters []model.ParentalLetter
query := buildArchivedLettersQuery(db, groupIDs, filters).
Preload("Location").
Limit(1000) // Cap for performance
if err := query.Find(&letters).Error; err != nil {
return 0, err
}
now := time.Now()
var count int64
for _, l := range letters {
if isLetterArchived(l, now) {
count++
}
}
return count, nil
}
func countArchivedMessages(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) (int64, error) {
// Fetch candidates and count with Go-side retention filtering
var messages []model.Message
query := buildArchivedMessagesQuery(db, groupIDs, filters).
Preload("Child.Group.Location").
Limit(1000) // Cap for performance
if err := query.Find(&messages).Error; err != nil {
return 0, err
}
now := time.Now()
var count int64
for _, m := range messages {
if isMessageArchived(m, now) {
count++
}
}
return count, nil
}
func countArchivedAbsences(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) (int64, error) {
// Absences don't have per-location retention, just count directly
var count int64
query := buildArchivedAbsencesQuery(db, groupIDs, filters)
err := query.Count(&count).Error
return count, err
}
// GetArchivedNews returns paginated archived news.
// Uses portable filtering: broad SQL query + Go-side retention filtering.
func GetArchivedNews(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) ([]model.News, error) {
if filters.Limit <= 0 {
filters.Limit = 20
}
// Over-fetch to account for items filtered out by per-location retention
fetchLimit := filters.Limit * 3
var news []model.News
query := buildArchivedNewsQuery(db, groupIDs, filters).
Preload("CreatedBy").
Preload("Location").
Preload("Group")
if filters.SortAsc {
query = query.Order("news.published_at ASC, news.id ASC")
if filters.BeforeID > 0 {
query = query.Where("news.id > ?", filters.BeforeID)
}
} else {
query = query.Order("news.published_at DESC, news.id DESC")
if filters.BeforeID > 0 {
query = query.Where("news.id < ?", filters.BeforeID)
}
}
if err := query.Limit(fetchLimit).Find(&news).Error; err != nil {
return nil, err
}
// Filter by per-location retention in Go
now := time.Now()
filtered := make([]model.News, 0, filters.Limit)
for _, n := range news {
if isNewsArchived(n, now) {
filtered = append(filtered, n)
if len(filtered) >= filters.Limit {
break
}
}
}
return filtered, nil
}
// isNewsArchived checks if a news item should be in the archive based on its location's retention setting.
func isNewsArchived(n model.News, now time.Time) bool {
// If valid_until is set and in the past, it's archived
if n.ValidUntil != nil && n.ValidUntil.Before(now) {
return true
}
// Check retention period based on location setting
retentionDays := 30 // default
if n.Location != nil && n.Location.NewsRetentionDays > 0 {
retentionDays = n.Location.NewsRetentionDays
}
retentionCutoff := now.AddDate(0, 0, -retentionDays)
return n.PublishedAt.Before(retentionCutoff)
}
// GetArchivedLetters returns paginated archived parental letters.
// Uses portable filtering: broad SQL query + Go-side retention filtering.
func GetArchivedLetters(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) ([]model.ParentalLetter, error) {
if filters.Limit <= 0 {
filters.Limit = 20
}
// Over-fetch to account for items filtered out by per-location retention
fetchLimit := filters.Limit * 3
var letters []model.ParentalLetter
query := buildArchivedLettersQuery(db, groupIDs, filters).
Preload("CreatedBy").
Preload("Location").
Preload("Group")
if filters.SortAsc {
query = query.Order("parental_letters.published_at ASC, parental_letters.id ASC")
if filters.BeforeID > 0 {
query = query.Where("parental_letters.id > ?", filters.BeforeID)
}
} else {
query = query.Order("parental_letters.published_at DESC, parental_letters.id DESC")
if filters.BeforeID > 0 {
query = query.Where("parental_letters.id < ?", filters.BeforeID)
}
}
if err := query.Limit(fetchLimit).Find(&letters).Error; err != nil {
return nil, err
}
// Filter by per-location retention in Go
now := time.Now()
filtered := make([]model.ParentalLetter, 0, filters.Limit)
for _, l := range letters {
if isLetterArchived(l, now) {
filtered = append(filtered, l)
if len(filtered) >= filters.Limit {
break
}
}
}
return filtered, nil
}
// isLetterArchived checks if a letter should be in the archive based on its location's retention setting.
func isLetterArchived(l model.ParentalLetter, now time.Time) bool {
// If valid_until is set and in the past, it's archived
if l.ValidUntil != nil && l.ValidUntil.Before(now) {
return true
}
// Check retention period based on location setting
// ParentalLetter.Location is not a pointer, check by ID
retentionDays := 90 // default
if l.Location.ID > 0 && l.Location.LetterRetentionDays > 0 {
retentionDays = l.Location.LetterRetentionDays
}
retentionCutoff := now.AddDate(0, 0, -retentionDays)
return l.PublishedAt != nil && l.PublishedAt.Before(retentionCutoff)
}
// GetArchivedMessages returns paginated archived parent messages.
// Uses portable filtering: broad SQL query + Go-side retention filtering.
func GetArchivedMessages(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) ([]model.Message, error) {
if filters.Limit <= 0 {
filters.Limit = 20
}
// Over-fetch to account for items filtered out by per-location retention
fetchLimit := filters.Limit * 3
var messages []model.Message
query := buildArchivedMessagesQuery(db, groupIDs, filters).
Preload("CreatedBy").
Preload("Child.Group.Location")
if filters.SortAsc {
query = query.Order("messages.published_at ASC, messages.id ASC")
if filters.BeforeID > 0 {
query = query.Where("messages.id > ?", filters.BeforeID)
}
} else {
query = query.Order("messages.published_at DESC, messages.id DESC")
if filters.BeforeID > 0 {
query = query.Where("messages.id < ?", filters.BeforeID)
}
}
if err := query.Limit(fetchLimit).Find(&messages).Error; err != nil {
return nil, err
}
// Filter by per-location retention in Go
now := time.Now()
filtered := make([]model.Message, 0, filters.Limit)
for _, m := range messages {
if isMessageArchived(m, now) {
filtered = append(filtered, m)
if len(filtered) >= filters.Limit {
break
}
}
}
return filtered, nil
}
// isMessageArchived checks if a message should be in the archive based on its location's retention setting.
func isMessageArchived(m model.Message, now time.Time) bool {
// If valid_until is set and in the past, it's archived
if m.ValidUntil != nil && m.ValidUntil.Before(now) {
return true
}
// Check retention period based on location setting (via child's group's location)
// Message.Child is not a pointer, but Child.Group and Group.Location are pointers
retentionDays := 14 // default
if m.Child.Group != nil && m.Child.Group.Location != nil && m.Child.Group.Location.MessageRetentionDays > 0 {
retentionDays = m.Child.Group.Location.MessageRetentionDays
}
retentionCutoff := now.AddDate(0, 0, -retentionDays)
return m.PublishedAt != nil && m.PublishedAt.Before(retentionCutoff)
}
// GetArchivedAbsences returns paginated archived absence notifications.
func GetArchivedAbsences(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) ([]model.AbsenceNotification, error) {
var absences []model.AbsenceNotification
query := buildArchivedAbsencesQuery(db, groupIDs, filters).
Preload("Child.Group.Location").
Preload("User")
if filters.Limit <= 0 {
filters.Limit = 20
}
if filters.SortAsc {
query = query.Order("absence_notifications.to_date ASC, absence_notifications.id ASC")
if filters.BeforeID > 0 {
query = query.Where("absence_notifications.id > ?", filters.BeforeID)
}
} else {
query = query.Order("absence_notifications.to_date DESC, absence_notifications.id DESC")
if filters.BeforeID > 0 {
query = query.Where("absence_notifications.id < ?", filters.BeforeID)
}
}
err := query.Limit(filters.Limit).Find(&absences).Error
return absences, err
}
// buildArchivedNewsQuery builds the base query for archived news.
// Uses broad SQL filter (1 day min retention) - precise filtering done in Go.
func buildArchivedNewsQuery(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) *gorm.DB {
now := time.Now()
// Use minimum possible retention (1 day) to get all potential candidates
// Precise per-location filtering happens in Go
minRetentionCutoff := now.AddDate(0, 0, -1)
query := db.Model(&model.News{}).
Joins("LEFT JOIN locations ON news.location_id = locations.id").
Where(`(
(news.valid_until IS NOT NULL AND news.valid_until < ?) OR
(news.published_at < ?)
)`, now, minRetentionCutoff)
// Apply group filter
if groupIDs != nil {
query = query.Where(`(news.group_id IN ? OR (news.group_id IS NULL AND news.location_id IN (SELECT location_id FROM "groups" WHERE id IN ?)))`, groupIDs, groupIDs)
}
// Apply search filter
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%"
query = query.Where("(news.title LIKE ? OR news.text LIKE ?)", searchPattern, searchPattern)
}
// Apply date filters
if filters.DateFrom != nil {
query = query.Where("news.published_at >= ?", *filters.DateFrom)
}
if filters.DateTo != nil {
endOfDay := time.Date(filters.DateTo.Year(), filters.DateTo.Month(), filters.DateTo.Day(), 23, 59, 59, 999999999, filters.DateTo.Location())
query = query.Where("news.published_at <= ?", endOfDay)
}
// Apply specific group filter from dropdown
if filters.GroupID > 0 {
query = query.Where("news.group_id = ?", filters.GroupID)
}
return query
}
// buildArchivedLettersQuery builds the base query for archived parental letters.
// Uses broad SQL filter (1 day min retention) - precise filtering done in Go.
func buildArchivedLettersQuery(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) *gorm.DB {
now := time.Now()
// Use minimum possible retention (1 day) to get all potential candidates
// Precise per-location filtering happens in Go
minRetentionCutoff := now.AddDate(0, 0, -1)
query := db.Model(&model.ParentalLetter{}).
Joins("LEFT JOIN locations ON parental_letters.location_id = locations.id").
Where("parental_letters.review_status = ?", "published").
Where(`(
(parental_letters.valid_until IS NOT NULL AND parental_letters.valid_until < ?) OR
(parental_letters.published_at < ?)
)`, now, minRetentionCutoff)
// Apply group filter
if groupIDs != nil {
query = query.Where(`(parental_letters.group_id IN ? OR (parental_letters.group_id IS NULL AND parental_letters.location_id IN (SELECT location_id FROM "groups" WHERE id IN ?)))`, groupIDs, groupIDs)
}
// Apply search filter
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%"
query = query.Where("(parental_letters.subject LIKE ? OR parental_letters.text LIKE ?)", searchPattern, searchPattern)
}
// Apply date filters
if filters.DateFrom != nil {
query = query.Where("parental_letters.published_at >= ?", *filters.DateFrom)
}
if filters.DateTo != nil {
endOfDay := time.Date(filters.DateTo.Year(), filters.DateTo.Month(), filters.DateTo.Day(), 23, 59, 59, 999999999, filters.DateTo.Location())
query = query.Where("parental_letters.published_at <= ?", endOfDay)
}
// Apply specific group filter from dropdown
if filters.GroupID > 0 {
query = query.Where("parental_letters.group_id = ?", filters.GroupID)
}
return query
}
// buildArchivedMessagesQuery builds the base query for archived parent messages.
// Uses broad SQL filter (1 day min retention) - precise filtering done in Go.
func buildArchivedMessagesQuery(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) *gorm.DB {
now := time.Now()
// Use minimum possible retention (1 day) to get all potential candidates
// Precise per-location filtering happens in Go
minRetentionCutoff := now.AddDate(0, 0, -1)
// Get location_id through child's group
query := db.Model(&model.Message{}).
Joins("LEFT JOIN children ON messages.child_id = children.id").
Joins(`LEFT JOIN "groups" ON children.group_id = groups.id`).
Joins("LEFT JOIN locations ON groups.location_id = locations.id").
Where("messages.draft = ?", false).
Where(`(
(messages.valid_until IS NOT NULL AND messages.valid_until < ?) OR
(messages.published_at < ?)
)`, now, minRetentionCutoff)
// Apply group filter
if groupIDs != nil {
query = query.Where("groups.id IN ?", groupIDs)
}
// Apply search filter
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%"
query = query.Where("(messages.subject LIKE ? OR messages.text LIKE ?)", searchPattern, searchPattern)
}
// Apply date filters
if filters.DateFrom != nil {
query = query.Where("messages.published_at >= ?", *filters.DateFrom)
}
if filters.DateTo != nil {
endOfDay := time.Date(filters.DateTo.Year(), filters.DateTo.Month(), filters.DateTo.Day(), 23, 59, 59, 999999999, filters.DateTo.Location())
query = query.Where("messages.published_at <= ?", endOfDay)
}
// Apply specific group filter from dropdown
if filters.GroupID > 0 {
query = query.Where("groups.id = ?", filters.GroupID)
}
return query
}
// buildArchivedAbsencesQuery builds the base query for archived absence notifications.
// Archived when: to_date < TODAY (past absences)
func buildArchivedAbsencesQuery(db *gorm.DB, groupIDs []uint, filters ArchiveFilters) *gorm.DB {
today := time.Now().Truncate(24 * time.Hour)
query := db.Model(&model.AbsenceNotification{}).
Joins("LEFT JOIN children ON absence_notifications.child_id = children.id").
Joins(`LEFT JOIN "groups" ON children.group_id = groups.id`).
Where("absence_notifications.to_date < ?", today)
// Apply group filter
if groupIDs != nil {
query = query.Where("groups.id IN ?", groupIDs)
}
// Apply search filter (search in child name or message)
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%"
query = query.Where("(children.first_name LIKE ? OR children.last_name LIKE ? OR absence_notifications.message LIKE ?)",
searchPattern, searchPattern, searchPattern)
}
// Apply date filters (filter by to_date)
if filters.DateFrom != nil {
query = query.Where("absence_notifications.to_date >= ?", *filters.DateFrom)
}
if filters.DateTo != nil {
endOfDay := time.Date(filters.DateTo.Year(), filters.DateTo.Month(), filters.DateTo.Day(), 23, 59, 59, 999999999, filters.DateTo.Location())
query = query.Where("absence_notifications.to_date <= ?", endOfDay)
}
// Apply specific group filter from dropdown
if filters.GroupID > 0 {
query = query.Where("groups.id = ?", filters.GroupID)
}
return query
}
// CanUserAccessArchive checks if a user can access the archive feature.
// Only employees and admins have access.
func CanUserAccessArchive(user *model.User) bool {
return user.IsEmployee() || user.IsGroupLeader() || user.IsHouseLeader() || user.IsAdmin()
}
package service
import (
"strings"
"time"
"unicode/utf8"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// CanUserCreateCalendarEvent checks if a user can create an event for the specified scope
// This considers effective roles - substitutes are downgraded to Employee at non-home locations
func CanUserCreateCalendarEvent(db *gorm.DB, user *model.User, groupId, locationId *uint) bool {
// Admin can create any event (including global)
if user.IsAdmin() {
return true
}
// Determine the target location for effective role check
var targetLocationID uint
if locationId != nil && *locationId > 0 {
targetLocationID = *locationId
} else if groupId != nil && *groupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *groupId).Error; err != nil {
return false
}
targetLocationID = group.LocationId
} else {
return false // No target specified (global events are admin-only)
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot create events
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can create for their location or any group in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can create for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If creating for a specific group, verify user is assigned to it
if groupId != nil && *groupId > 0 {
for _, g := range userGroups {
if g.ID == *groupId {
return true
}
}
return false
}
// GroupLead cannot create location-wide events
return false
}
return false
}
// CanUserEditCalendarEvent checks if a user can edit a specific event
// This considers effective roles - substitutes are downgraded to Employee at non-home locations
func CanUserEditCalendarEvent(db *gorm.DB, user *model.User, event *model.CalendarEvent) bool {
// Admin can edit any event (including global)
if user.IsAdmin() {
return true
}
// Global events (LocationId == nil) can only be edited by admins
if event.LocationId == nil {
return false
}
// Author can always edit their own events
if event.CreatedById == user.ID {
return true
}
// Determine the target location for effective role check
targetLocationID := *event.LocationId
if event.GroupId != nil && *event.GroupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *event.GroupId).Error; err == nil {
targetLocationID = group.LocationId
}
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot edit events
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can edit location-wide or group events in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can edit events for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If event is for a specific group, verify user is assigned to it
if event.GroupId != nil && *event.GroupId > 0 {
for _, g := range userGroups {
if g.ID == *event.GroupId {
return true
}
}
return false
}
// If event is location-wide, check if location matches user's groups
for _, g := range userGroups {
if g.LocationId == *event.LocationId {
return true
}
}
return false
}
return false
}
// CanUserDeleteCalendarEvent checks if a user can delete a specific event
func CanUserDeleteCalendarEvent(db *gorm.DB, user *model.User, event *model.CalendarEvent) bool {
return CanUserEditCalendarEvent(db, user, event)
}
// GetEventsForDateRange returns events visible to the user within a date range
func GetEventsForDateRange(db *gorm.DB, user *model.User, start, end time.Time, adminLocationId *uint, groupIds []uint) ([]model.CalendarEvent, error) {
var events []model.CalendarEvent
// Normalize dates to start/end of day
startOfDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
endOfDay := time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location())
query := db.Where("deleted_at IS NULL").
Where("(start_date <= ? AND end_date >= ?) OR (start_date BETWEEN ? AND ?)",
endOfDay, startOfDay, startOfDay, endOfDay).
Order("start_date ASC, title ASC").
Preload("Group").
Preload("Location").
Preload("CreatedBy")
// Build scope conditions based on user access
if user.IsAdmin() {
if adminLocationId != nil && *adminLocationId > 0 {
// Admin with location filter - show events for that location, its groups, and global
var locationGroups []model.Group
db.Where("location_id = ?", *adminLocationId).Find(&locationGroups)
var locationGroupIds []uint
for _, g := range locationGroups {
locationGroupIds = append(locationGroupIds, g.ID)
}
query = query.Where(
"location_id IS NULL OR location_id = ? OR group_id IN ?",
*adminLocationId, locationGroupIds,
)
}
// Admin without filter sees all events
} else if len(groupIds) > 0 {
// Standard user with group access
// Show: global events, location-wide events (no specific group), or group-specific events for user's groups
locationIds := getLocationIdsFromGroups(db, groupIds)
query = query.Where(
"location_id IS NULL OR (location_id IN ? AND group_id IS NULL) OR group_id IN ?",
locationIds, groupIds,
)
} else {
// No groups - only show global events
query = query.Where("location_id IS NULL")
}
// Exclude employee-only events for non-employee users (parents)
if !user.IsEmployee() && !user.IsAdmin() {
query = query.Where("employee_only = ?", false)
}
err := query.Find(&events).Error
return events, err
}
// getLocationIdsFromGroups extracts unique location IDs from a list of group IDs
func getLocationIdsFromGroups(db *gorm.DB, groupIds []uint) []uint {
if len(groupIds) == 0 {
return []uint{}
}
var groups []model.Group
db.Where("id IN ?", groupIds).Find(&groups)
locationMap := make(map[uint]bool)
for _, g := range groups {
locationMap[g.LocationId] = true
}
var locationIds []uint
for id := range locationMap {
locationIds = append(locationIds, id)
}
return locationIds
}
// GetCalendarEventByID retrieves a calendar event by its ID with preloaded relations
func GetCalendarEventByID(db *gorm.DB, id uint) (*model.CalendarEvent, error) {
var event model.CalendarEvent
err := db.Preload("Group").
Preload("Location").
Preload("CreatedBy").
First(&event, id).Error
if err != nil {
return nil, err
}
return &event, nil
}
// CreateCalendarEvent creates a new calendar event
func CreateCalendarEvent(db *gorm.DB, event *model.CalendarEvent) error {
return db.Create(event).Error
}
// UpdateCalendarEvent updates an existing calendar event
func UpdateCalendarEvent(db *gorm.DB, event *model.CalendarEvent) error {
return db.Save(event).Error
}
// CancelCalendarEvent marks an event as cancelled
func CancelCalendarEvent(db *gorm.DB, event *model.CalendarEvent, cancelledBy uint) error {
now := time.Now()
event.Cancelled = true
event.CancelledAt = &now
event.CancelledBy = &cancelledBy
return db.Save(event).Error
}
// DeleteCalendarEvent soft-deletes a calendar event
func DeleteCalendarEvent(db *gorm.DB, event *model.CalendarEvent) error {
return db.Delete(event).Error
}
// GetUserGroupIds returns all group IDs the user has access to
func GetUserGroupIds(db *gorm.DB, user *model.User) []uint {
var groupIds []uint
if user.IsParent() {
// Get groups from user's children
var children []model.Child
db.Joins("JOIN user_children ON user_children.child_id = children.id").
Where("user_children.user_id = ?", user.ID).
Where("children.group_id IS NOT NULL").
Find(&children)
for _, child := range children {
if child.GroupId != nil {
groupIds = append(groupIds, *child.GroupId)
}
}
} else if user.IsEmployee() || user.IsGroupLeader() || user.IsHouseLeader() {
// Get groups for viewing (includes substitute groups for intranet employees)
groups, _ := user.GetEmployeeGroups(db)
for _, g := range groups {
groupIds = append(groupIds, g.ID)
}
}
// Remove duplicates
seen := make(map[uint]bool)
var uniqueIds []uint
for _, id := range groupIds {
if !seen[id] {
seen[id] = true
uniqueIds = append(uniqueIds, id)
}
}
return uniqueIds
}
// GetUserCreatableGroups returns groups the user can create events for
func GetUserCreatableGroups(db *gorm.DB, user *model.User) ([]model.Group, error) {
var groups []model.Group
if user.IsAdmin() {
// Admin can create for any group
err := db.Preload("Location").Order("locations.name, groups.name").
Joins("JOIN locations ON locations.id = groups.location_id").
Find(&groups).Error
return groups, err
}
if user.IsHouseLeader() {
// LocationLead can create for groups in their locations
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return groups, nil
}
err = db.Preload("Location").
Where("location_id IN ?", userLocationIDs).
Order("name").
Find(&groups).Error
return groups, err
}
if user.IsGroupLeader() {
// GroupLead can only create for their core team groups
// (handles intranet-linked employees - no creation rights in substitute groups)
coreTeamGroups, err := user.GetEmployeeCoreTeamGroups(db)
return coreTeamGroups, err
}
return groups, nil
}
// GetUserCreatableLocations returns locations the user can create location-wide events for
func GetUserCreatableLocations(db *gorm.DB, user *model.User) ([]model.Location, error) {
var locations []model.Location
if user.IsAdmin() {
// Admin can create for any location
err := db.Order("name").Find(&locations).Error
return locations, err
}
if user.IsHouseLeader() {
// LocationLead can create for their assigned locations
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return locations, nil
}
err = db.Where("id IN ?", userLocationIDs).Order("name").Find(&locations).Error
return locations, err
}
// GroupLead cannot create location-wide events
return locations, nil
}
// ComputeGroupAbbreviations computes short display labels for a set of groups.
// Groups sharing a common prefix that differ only by a trailing suffix get
// the first 2 uppercase letters of the prefix + the distinguishing suffix
// (e.g. "Lämmergruppe 1", "Lämmergruppe 2" -> "LÄ1", "LÄ2").
// Groups without a shared prefix get first 3 meaningful uppercase characters.
func ComputeGroupAbbreviations(groups []model.Group) map[uint]string {
result := make(map[uint]string)
if len(groups) == 0 {
return result
}
// Group by location to compute abbreviations per location
byLocation := make(map[uint][]model.Group)
for _, g := range groups {
byLocation[g.LocationId] = append(byLocation[g.LocationId], g)
}
for _, locGroups := range byLocation {
if len(locGroups) == 1 {
result[locGroups[0].ID] = abbreviateSingle(locGroups[0].Name)
continue
}
// Strip common articles for prefix detection
names := make([]string, len(locGroups))
for i, g := range locGroups {
names[i] = stripArticle(g.Name)
}
prefix := longestCommonPrefix(names)
// If we have a meaningful shared prefix (at least 3 chars) and groups
// differ only by a short suffix, use prefix abbreviation + suffix
if utf8.RuneCountInString(prefix) >= 3 {
prefixAbbrev := upperFirstNRunes(prefix, 2)
allHaveSuffix := true
for _, name := range names {
suffix := strings.TrimSpace(strings.TrimPrefix(name, prefix))
if suffix == "" {
allHaveSuffix = false
break
}
}
if allHaveSuffix {
for i, g := range locGroups {
suffix := strings.TrimSpace(strings.TrimPrefix(names[i], prefix))
result[g.ID] = prefixAbbrev + suffix
}
continue
}
}
// Fallback: each group gets its own 3-letter abbreviation
for _, g := range locGroups {
result[g.ID] = abbreviateSingle(g.Name)
}
}
return result
}
// stripArticle removes common leading articles from a group name.
func stripArticle(name string) string {
for _, article := range []string{"The ", "Die ", "Das ", "Der "} {
if strings.HasPrefix(name, article) {
return strings.TrimPrefix(name, article)
}
}
return name
}
// longestCommonPrefix finds the longest common prefix of a set of strings.
func longestCommonPrefix(strs []string) string {
if len(strs) == 0 {
return ""
}
prefix := strs[0]
for _, s := range strs[1:] {
for !strings.HasPrefix(s, prefix) {
// Remove last rune
_, size := utf8.DecodeLastRuneInString(prefix)
prefix = prefix[:len(prefix)-size]
if prefix == "" {
return ""
}
}
}
return prefix
}
// upperFirstNRunes returns the first n runes of s, uppercased.
func upperFirstNRunes(s string, n int) string {
s = strings.TrimSpace(s)
runes := []rune(strings.ToUpper(s))
if len(runes) > n {
runes = runes[:n]
}
return string(runes)
}
// abbreviateSingle creates a 3-letter uppercase abbreviation from a name.
func abbreviateSingle(name string) string {
name = stripArticle(name)
return upperFirstNRunes(name, 3)
}
// GetCalendarGroupAbbreviations fetches groups by IDs and computes their abbreviations.
func GetCalendarGroupAbbreviations(db *gorm.DB, groupIDs []uint) map[uint]string {
if len(groupIDs) == 0 {
return make(map[uint]string)
}
// Get all unique location IDs for the given groups
var groups []model.Group
db.Where("id IN ?", groupIDs).Find(&groups)
if len(groups) == 0 {
return make(map[uint]string)
}
// Get location IDs
locationIDs := make(map[uint]bool)
for _, g := range groups {
locationIDs[g.LocationId] = true
}
// Fetch ALL groups for those locations (needed for correct abbreviation context)
var locIDs []uint
for id := range locationIDs {
locIDs = append(locIDs, id)
}
var allLocationGroups []model.Group
db.Where("location_id IN ?", locIDs).Find(&allLocationGroups)
// Compute abbreviations for all groups at those locations
allAbbrevs := ComputeGroupAbbreviations(allLocationGroups)
// Filter to only requested group IDs
result := make(map[uint]string)
for _, id := range groupIDs {
if abbrev, ok := allAbbrevs[id]; ok {
result[id] = abbrev
}
}
return result
}
package service
import (
"fmt"
"os"
"time"
"wippidu_app_backend/internal/i18n"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// GenerateNewsFromCalendarEvent creates a news post from a calendar event.
// The news inherits the event's scope (global/location/group) and is linked back to the event.
func GenerateNewsFromCalendarEvent(db *gorm.DB, event *model.CalendarEvent, lang string) (*model.News, error) {
eventTypeName := i18n.Translate(lang, event.EventTypeI18nKey())
title := i18n.TranslateWithData(lang, "calendar.news.autotitle", map[string]interface{}{
"EventType": eventTypeName,
"Title": event.Title,
})
dateStr := formatEventDateRange(event, lang)
eventURL := buildCalendarEventURL(event.ID)
body := i18n.TranslateWithData(lang, "calendar.news.autotext", map[string]interface{}{
"Title": event.Title,
"Date": dateStr,
"Description": event.Description,
"Link": eventURL,
})
now := time.Now()
eventID := event.ID
news := model.News{
Title: title,
Text: body,
CreatedById: event.CreatedById,
PublishedAt: now,
LocationId: event.LocationId,
GroupId: event.GroupId,
CalendarEventId: &eventID,
}
if err := db.Create(&news).Error; err != nil {
return nil, fmt.Errorf("failed to create news from calendar event: %w", err)
}
// Link news back to the event
if err := db.Model(event).Update("linked_news_id", news.ID).Error; err != nil {
logger.Error("calendar news: failed to link news to event",
"newsID", news.ID,
"eventID", event.ID,
"error", err)
}
return &news, nil
}
// GenerateReminderNewsForEvent creates a reminder news post for an upcoming calendar event.
func GenerateReminderNewsForEvent(db *gorm.DB, event *model.CalendarEvent, lang string) (*model.News, error) {
title := i18n.TranslateWithData(lang, "calendar.news.remindertitle", map[string]interface{}{
"Title": event.Title,
})
dateStr := formatEventDateRange(event, lang)
eventURL := buildCalendarEventURL(event.ID)
body := i18n.TranslateWithData(lang, "calendar.news.remindertext", map[string]interface{}{
"Title": event.Title,
"Date": dateStr,
"Description": event.Description,
"Link": eventURL,
})
now := time.Now()
eventID := event.ID
news := model.News{
Title: title,
Text: body,
CreatedById: event.CreatedById,
PublishedAt: now,
LocationId: event.LocationId,
GroupId: event.GroupId,
CalendarEventId: &eventID,
}
if err := db.Create(&news).Error; err != nil {
return nil, fmt.Errorf("failed to create reminder news for event: %w", err)
}
// Link reminder news back to the event
if err := db.Model(event).Update("reminder_news_id", news.ID).Error; err != nil {
logger.Error("calendar news: failed to link reminder news to event",
"newsID", news.ID,
"eventID", event.ID,
"error", err)
}
return &news, nil
}
// formatEventDateRange returns a human-readable date/time string for the event.
func formatEventDateRange(event *model.CalendarEvent, lang string) string {
if lang == "en" {
if event.AllDay {
if event.IsMultiDay() {
return event.StartDate.Format("Jan 2, 2006") + " – " + event.EndDate.Format("Jan 2, 2006")
}
return event.StartDate.Format("Jan 2, 2006")
}
if event.IsMultiDay() {
return event.StartDate.Format("Jan 2, 2006 3:04 PM") + " – " + event.EndDate.Format("Jan 2, 2006 3:04 PM")
}
return event.StartDate.Format("Jan 2, 2006 3:04 PM") + " – " + event.EndDate.Format("3:04 PM")
}
// German format (default)
if event.AllDay {
if event.IsMultiDay() {
return event.StartDate.Format("02.01.2006") + " – " + event.EndDate.Format("02.01.2006")
}
return event.StartDate.Format("02.01.2006")
}
if event.IsMultiDay() {
return event.StartDate.Format("02.01.2006 15:04") + " – " + event.EndDate.Format("02.01.2006 15:04")
}
return event.StartDate.Format("02.01.2006 15:04") + " – " + event.EndDate.Format("15:04")
}
// buildCalendarEventURL constructs the URL for a calendar event detail page.
func buildCalendarEventURL(eventID uint) string {
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
return fmt.Sprintf("%s://%s/de/calendar/event/%d", scheme, baseURL, eventID)
}
package service
import (
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// CanUserManageCluster checks if a user can create/edit/delete clusters for a group
// This considers effective roles - substitutes are downgraded to Employee at non-home locations
// Returns true for:
// - Admin
// - LocationLead (effective) with access to the group's location
// - GroupLead (effective) assigned to the specific group
func CanUserManageCluster(db *gorm.DB, user *model.User, groupID uint) bool {
if user.IsAdmin() {
return true
}
// Get the group to check its location
var group model.Group
if err := db.First(&group, groupID).Error; err != nil {
return false
}
// Check effective role at the group's location
// Substitutes are downgraded to Employee and cannot manage clusters
effectiveRole := user.GetEffectiveRoleForLocation(db, group.LocationId)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil {
return false
}
// Check if group's location is in user's assigned locations
hasAccess := false
for _, locID := range locationIDs {
if group.LocationId == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can manage clusters for any group at their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) - must be assigned to the specific group
if effectiveRole.Role == "GroupLead" {
var count int64
db.Table("group_teachers").
Where("user_id = ? AND group_id = ?", user.ID, groupID).
Count(&count)
return count > 0
}
return false
}
// GetClustersForGroup returns all clusters for a specific group
func GetClustersForGroup(db *gorm.DB, groupID uint) ([]model.ChildCluster, error) {
var clusters []model.ChildCluster
err := db.Where("group_id = ?", groupID).
Preload("Children").
Preload("Group").
Order("name ASC").
Find(&clusters).Error
return clusters, err
}
// GetClustersForGroups returns clusters for multiple groups (for employees with multiple groups)
func GetClustersForGroups(db *gorm.DB, groupIDs []uint) ([]model.ChildCluster, error) {
var clusters []model.ChildCluster
if len(groupIDs) == 0 {
return clusters, nil
}
err := db.Where("group_id IN ?", groupIDs).
Preload("Children").
Preload("Group").
Order("name ASC").
Find(&clusters).Error
return clusters, err
}
// GetChildIDsForCluster returns all child IDs in a cluster
func GetChildIDsForCluster(db *gorm.DB, clusterID uint) ([]uint, error) {
var childIDs []uint
err := db.Table("child_cluster_children").
Where("child_cluster_id = ?", clusterID).
Pluck("child_id", &childIDs).Error
return childIDs, err
}
// GetGroupsForClusterManagement returns groups that a user can manage clusters for
// For LocationLead: all groups at their location
// For GroupLead: only groups they are assigned to
func GetGroupsForClusterManagement(db *gorm.DB, user *model.User) ([]model.Group, error) {
var groups []model.Group
if user.IsAdmin() {
// Admin can see all groups
err := db.Preload("Location").Order("name ASC").Find(&groups).Error
return groups, err
}
// Get groups where user is assigned
var assignedGroups []model.Group
db.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", user.ID).
Preload("Location").
Find(&assignedGroups)
groupIDSet := make(map[uint]bool)
for _, g := range assignedGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
// For LocationLead, also include all groups at their locations
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(db)
if err == nil && len(locationIDs) > 0 {
var locationGroups []model.Group
db.Where("location_id IN ?", locationIDs).
Preload("Location").
Find(&locationGroups)
for _, g := range locationGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
}
}
return groups, nil
}
// Package dbexport produces a gzipped JSON dump of the full database for
// the staging-refresh workflow (#459).
//
// The dump is a single JSON object: a header (schema version, app version,
// timestamp, source host, options) plus a "tables" map keyed by stable
// table name. Each table is a flat array of rows; foreign keys are
// preserved by ID so the receiver can reload everything with IDs intact.
//
// Soft-deleted rows are included (Unscoped) to keep the target's audit
// history aligned with the source.
//
// Attachment binary content travels inline as base64 (Go's default
// []byte JSON encoding). When IncludeAttachments=false the row is still
// emitted so foreign keys resolve, but Content is blanked.
package dbexport
import (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// SchemaVersion is the export-format version. Bump on any breaking change
// to the JSON shape so importers can refuse incompatible files.
const SchemaVersion = 1
// BuildVersion is the running app version. Main wires this from its own
// Version variable at startup so dumps record which release produced them.
var BuildVersion = "dev"
// Options controls what the export contains.
type Options struct {
// IncludeAttachments keeps attachment Content blobs inline. When false,
// rows are still emitted with blanked Content so foreign keys resolve.
IncludeAttachments bool
// ExportedFrom records the originating instance (e.g. "app.wippidu.net").
// Goes into the header for the import-side audit trail.
ExportedFrom string
}
// Header is the JSON header of every export file.
type Header struct {
SchemaVersion int `json:"schema_version"`
AppVersion string `json:"app_version"`
ExportedAt string `json:"exported_at"`
ExportedFrom string `json:"exported_from"`
IncludeAttachments bool `json:"include_attachments"`
}
// Dump is the on-disk shape. "tables" uses RawMessage so each table is
// streamed without a second marshal/unmarshal pass through generic maps.
type Dump struct {
Header Header `json:"header"`
Tables map[string]json.RawMessage `json:"tables"`
}
// Export writes a gzipped JSON dump of the database to w.
//
// Each table is queried with Unscoped() so soft-deleted rows survive the
// round-trip. Junction tables (many2many joins without Go models) are
// queried as raw rows and emitted as arrays of column-keyed objects.
//
// A missing table is treated as empty and skipped from the dump. This is
// the import-side schema-tolerance principle applied symmetrically: a
// source running an older release than the target should still produce a
// usable dump for the overlapping schema.
func Export(db *gorm.DB, w io.Writer, opts Options) error {
gz := gzip.NewWriter(w)
defer gz.Close()
dump := Dump{
Header: Header{
SchemaVersion: SchemaVersion,
AppVersion: BuildVersion,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
ExportedFrom: opts.ExportedFrom,
IncludeAttachments: opts.IncludeAttachments,
},
Tables: make(map[string]json.RawMessage, len(ModelTables)+len(JunctionTables)),
}
for _, spec := range ModelTables {
if !db.Migrator().HasTable(spec.NewSlice()) {
continue
}
rows := spec.NewSlice()
if err := db.Unscoped().Find(rows).Error; err != nil {
return fmt.Errorf("export %s: %w", spec.Name, err)
}
// Blank attachment content if the operator opted out, but keep rows
// so foreign keys (parental letters, messages, ...) still resolve.
if spec.Name == "attachments" && !opts.IncludeAttachments {
atts := rows.(*[]model.Attachment)
for i := range *atts {
(*atts)[i].Content = nil
}
}
raw, err := json.Marshal(rows)
if err != nil {
return fmt.Errorf("marshal %s: %w", spec.Name, err)
}
dump.Tables[spec.Name] = raw
}
for _, table := range JunctionTables {
// Tolerate junction tables that don't exist in this DB (test setups
// often only migrate a subset). They'll simply be absent from the dump.
if !db.Migrator().HasTable(table) {
continue
}
var rows []map[string]interface{}
if err := db.Table(table).Find(&rows).Error; err != nil {
return fmt.Errorf("export junction %s: %w", table, err)
}
raw, err := json.Marshal(rows)
if err != nil {
return fmt.Errorf("marshal junction %s: %w", table, err)
}
dump.Tables[table] = raw
}
return json.NewEncoder(gz).Encode(dump)
}
package dbexport
// tables.go enumerates every persisted entity in the application database.
// Each entry pairs a stable export name (used as the JSON key) with a
// factory that returns a pointer to a zero-valued slice of that model.
//
// Adding a new model? Append it here AND to the import side in
// internal/service/dbimport. Forgetting either side will silently drop
// the table from staging refreshes.
import "wippidu_app_backend/internal/model"
// TableSpec describes one exported table.
type TableSpec struct {
// Name is the JSON key under "tables" in the dump.
Name string
// NewSlice returns a pointer to an empty slice of the model type,
// suitable for passing to gorm.DB.Find.
NewSlice func() interface{}
}
// ModelTables: every model that participates in InitDB's AutoMigrate call.
// Order matches model.InitDB so it's easy to spot drift on review.
var ModelTables = []TableSpec{
{"users", func() interface{} { return &[]model.User{} }},
{"news", func() interface{} { return &[]model.News{} }},
{"children", func() interface{} { return &[]model.Child{} }},
{"enrollments", func() interface{} { return &[]model.Enrollment{} }},
{"groups", func() interface{} { return &[]model.Group{} }},
{"child_clusters", func() interface{} { return &[]model.ChildCluster{} }},
{"absence_notifications", func() interface{} { return &[]model.AbsenceNotification{} }},
{"roles", func() interface{} { return &[]model.Role{} }},
{"locations", func() interface{} { return &[]model.Location{} }},
{"news_reads", func() interface{} { return &[]model.NewsRead{} }},
{"documents", func() interface{} { return &[]model.Document{} }},
{"blackboard_documents", func() interface{} { return &[]model.BlackboardDocument{} }},
{"messages", func() interface{} { return &[]model.Message{} }},
{"message_reads", func() interface{} { return &[]model.MessageRead{} }},
{"message_templates", func() interface{} { return &[]model.MessageTemplate{} }},
{"parental_letters", func() interface{} { return &[]model.ParentalLetter{} }},
{"parental_letter_reads", func() interface{} { return &[]model.ParentalLetterRead{} }},
{"poll_options", func() interface{} { return &[]model.PollOption{} }},
{"poll_votes", func() interface{} { return &[]model.PollVote{} }},
{"survey_questions", func() interface{} { return &[]model.SurveyQuestion{} }},
{"survey_responses", func() interface{} { return &[]model.SurveyResponse{} }},
{"table_columns", func() interface{} { return &[]model.TableColumn{} }},
{"table_rows", func() interface{} { return &[]model.TableRow{} }},
{"table_cells", func() interface{} { return &[]model.TableCell{} }},
{"attachments", func() interface{} { return &[]model.Attachment{} }},
{"passwds", func() interface{} { return &[]model.Passwd{} }},
{"pins", func() interface{} { return &[]model.Pin{} }},
{"location_devices", func() interface{} { return &[]model.LocationDevice{} }},
{"user_children", func() interface{} { return &[]model.UserChild{} }},
{"api_tokens", func() interface{} { return &[]model.APIToken{} }},
{"sync_persons", func() interface{} { return &[]model.SyncPerson{} }},
{"sync_parent_children", func() interface{} { return &[]model.SyncParentChild{} }},
{"sync_belegungen", func() interface{} { return &[]model.SyncBelegung{} }},
{"sync_employees", func() interface{} { return &[]model.SyncEmployee{} }},
{"sync_locations", func() interface{} { return &[]model.SyncLocation{} }},
{"sync_groups", func() interface{} { return &[]model.SyncGroup{} }},
{"sync_location_leads", func() interface{} { return &[]model.SyncLocationLead{} }},
{"sync_group_leads", func() interface{} { return &[]model.SyncGroupLead{} }},
{"sync_processing_logs", func() interface{} { return &[]model.SyncProcessingLog{} }},
{"sync_receive_logs", func() interface{} { return &[]model.SyncReceiveLog{} }},
{"sync_settings", func() interface{} { return &[]model.SyncSettings{} }},
{"registration_requests", func() interface{} { return &[]model.RegistrationRequest{} }},
{"email_settings", func() interface{} { return &[]model.EmailSettings{} }},
{"invitation_codes", func() interface{} { return &[]model.InvitationCode{} }},
{"employee_invitation_codes", func() interface{} { return &[]model.EmployeeInvitationCode{} }},
{"legal_settings", func() interface{} { return &[]model.LegalSettings{} }},
{"faq_settings", func() interface{} { return &[]model.FAQSettings{} }},
{"calendar_events", func() interface{} { return &[]model.CalendarEvent{} }},
{"delegation_settings", func() interface{} { return &[]model.DelegationSettings{} }},
{"session_settings", func() interface{} { return &[]model.SessionSettings{} }},
{"employee_chats", func() interface{} { return &[]model.EmployeeChat{} }},
{"chat_messages", func() interface{} { return &[]model.ChatMessage{} }},
{"chat_message_reads", func() interface{} { return &[]model.ChatMessageRead{} }},
{"changelog_entries", func() interface{} { return &[]model.ChangelogEntry{} }},
{"employee_daily_groups", func() interface{} { return &[]model.EmployeeDailyGroup{} }},
{"intranet_settings", func() interface{} { return &[]model.IntranetSettings{} }},
{"holiday_settings", func() interface{} { return &[]model.HolidaySettings{} }},
}
// JunctionTables: many2many join tables that GORM manages implicitly and
// that have no dedicated Go model. Exported via raw queries.
var JunctionTables = []string{
"user_roles",
"group_teachers",
"child_cluster_children",
"message_recipients",
"employee_chat_participants",
}
package dbimport
// Anonymization (#459). When refreshing staging from production data we
// scrub PII by default. The operator can opt out per-category, but doing
// so requires typing a risk-acknowledgement string into the form — see
// the controller for the gate. This package just runs the rules.
//
// All transformations are deterministic given the source row's ID, so
// re-importing the same export yields the same fake values — a real
// bug-repro on staging can be discussed without the names drifting
// between sessions.
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/util"
)
// StagingPassword is what every password hash gets reset to when the
// "reset password hashes" anonymizer runs. Documented in the operator
// runbook so staging users know which password to log in with.
const StagingPassword = "staging123"
// scrubbedBody is the placeholder text inserted in place of message and
// letter bodies. Long enough that it's visually obvious in the staging UI.
const scrubbedBody = "[scrubbed for staging]"
// nameSeed is a fixed string mixed into the per-row hash so the
// real→fake mapping is stable across runs (and across operators).
// Changing it reshuffles every fake name in the next import.
const nameSeed = "wippidu-staging-anonymizer-v1"
// fakeFirstNames and fakeLastNames are small German pools. ~30 each
// gives ~900 unique combinations — ample for staging-sized data.
var fakeFirstNames = []string{
"Anna", "Lara", "Mia", "Lena", "Marie", "Sophie", "Hannah", "Emma",
"Lina", "Lea", "Elena", "Charlotte", "Ida", "Klara", "Greta",
"Max", "Paul", "Leon", "Felix", "Noah", "Ben", "Jonas", "Tim",
"Tom", "Lukas", "Liam", "Elias", "Henri", "Finn", "Anton",
}
var fakeLastNames = []string{
"Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer",
"Wagner", "Becker", "Schulz", "Hoffmann", "Schäfer", "Koch",
"Bauer", "Richter", "Klein", "Wolf", "Schröder", "Neumann",
"Schwarz", "Zimmermann", "Braun", "Krüger", "Hofmann", "Hartmann",
"Lange", "Schmitt", "Werner", "Schmitz", "Krause", "Meier",
}
// dummyAttachmentContent is a 1×1 transparent PNG. Replaces all
// attachment bodies when the anonymizer runs — keeps the row in place
// so foreign keys resolve, but throws away potentially sensitive bytes.
var dummyAttachmentContent = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
// AnonymizeOptions controls which scrubbers run during import.
// Every flag defaults to true (i.e. "scrub it") for safety; the
// import form lets an operator turn them off, but only after typing
// the risk-acknowledgement string.
type AnonymizeOptions struct {
Emails bool
Names bool
Contacts bool
PasswordHashes bool
Bodies bool
Attachments bool
}
// DefaultAnonymize returns the all-on configuration that matches the
// form's checkbox defaults.
func DefaultAnonymize() AnonymizeOptions {
return AnonymizeOptions{
Emails: true,
Names: true,
Contacts: true,
PasswordHashes: true,
Bodies: true,
Attachments: true,
}
}
// Any reports whether at least one anonymizer is enabled. Used by the
// controller to decide whether to skip running the loop entirely.
func (o AnonymizeOptions) Any() bool {
return o.Emails || o.Names || o.Contacts || o.PasswordHashes || o.Bodies || o.Attachments
}
// AnyDisabled reports whether the operator opted out of any anonymizer.
// The controller uses this to require the PII risk-acknowledgement.
func (o AnonymizeOptions) AnyDisabled() bool {
return !o.Emails || !o.Names || !o.Contacts || !o.PasswordHashes || !o.Bodies || !o.Attachments
}
// pickFromList deterministically selects an entry from list using a
// SHA-256 over the seed + table + id. The same (table, id) pair always
// resolves to the same entry.
func pickFromList(list []string, table string, id uint) string {
h := sha256.New()
h.Write([]byte(nameSeed))
h.Write([]byte(table))
var idBytes [8]byte
binary.BigEndian.PutUint64(idBytes[:], uint64(id))
h.Write(idBytes[:])
sum := h.Sum(nil)
idx := binary.BigEndian.Uint64(sum[:8]) % uint64(len(list))
return list[idx]
}
// fakeFullName returns a (firstName, lastName) tuple for the row,
// stable per (table, id).
func fakeFullName(table string, id uint) (string, string) {
return pickFromList(fakeFirstNames, table+":first", id),
pickFromList(fakeLastNames, table+":last", id)
}
// preparedHash is computed once per import so we don't run bcrypt
// per-row (the staging dataset has ~thousands of rows, and bcrypt is
// deliberately slow).
type preparedHash struct {
hash string
err error
}
func newPreparedHash() *preparedHash {
h, err := util.GenerateHashPassword(StagingPassword)
return &preparedHash{hash: h, err: err}
}
// anonymizeUsers scrubs PII on User rows. Operates in-place.
func anonymizeUsers(users []model.User, opts AnonymizeOptions) {
for i := range users {
u := &users[i]
if opts.Emails {
u.Email = fmt.Sprintf("user-%d@staging.local", u.ID)
}
if opts.Names {
u.FirstName, u.LastName = fakeFullName("users", u.ID)
}
if opts.Contacts {
u.Address = ""
u.PhoneNumber = ""
}
}
}
// anonymizeChildren scrubs PII on Child rows.
func anonymizeChildren(children []model.Child, opts AnonymizeOptions) {
if !opts.Names {
return
}
for i := range children {
c := &children[i]
c.FirstName, c.LastName = fakeFullName("children", c.ID)
c.MiddleNames = ""
}
}
// anonymizePasswds replaces every bcrypt hash with one prepared
// hash of the documented staging password.
func anonymizePasswds(passwds []model.Passwd, ph *preparedHash) error {
if ph.err != nil {
return ph.err
}
for i := range passwds {
passwds[i].PassHash = ph.hash
}
return nil
}
// anonymizeMessages scrubs message bodies.
func anonymizeMessages(messages []model.Message) {
for i := range messages {
messages[i].Subject = scrubbedBody
messages[i].Text = scrubbedBody
}
}
// anonymizeParentalLetters scrubs letter bodies.
func anonymizeParentalLetters(letters []model.ParentalLetter) {
for i := range letters {
letters[i].Subject = scrubbedBody
letters[i].Text = scrubbedBody
}
}
// anonymizeChatMessages scrubs chat-message bodies.
func anonymizeChatMessages(msgs []model.ChatMessage) {
for i := range msgs {
msgs[i].Text = scrubbedBody
}
}
// anonymizeAttachments replaces attachment binary content with the
// dummy 1×1 PNG. Filename and MimeType are left in place so the
// staging UI still has something to render.
func anonymizeAttachments(atts []model.Attachment) {
for i := range atts {
atts[i].Content = append(atts[i].Content[:0], dummyAttachmentContent...)
}
}
// applyAnonymizers dispatches by table name to the appropriate scrubber.
// Tables that don't carry PII (most of them) are a no-op. Called once per
// table during the import loop, before the typed slice is inserted.
func applyAnonymizers(tableName string, slice interface{}, opts AnonymizeOptions, ph *preparedHash) error {
switch tableName {
case "users":
if opts.Emails || opts.Names || opts.Contacts {
anonymizeUsers(*slice.(*[]model.User), opts)
}
case "children":
if opts.Names {
anonymizeChildren(*slice.(*[]model.Child), opts)
}
case "passwds":
if opts.PasswordHashes {
return anonymizePasswds(*slice.(*[]model.Passwd), ph)
}
case "messages":
if opts.Bodies {
anonymizeMessages(*slice.(*[]model.Message))
}
case "parental_letters":
if opts.Bodies {
anonymizeParentalLetters(*slice.(*[]model.ParentalLetter))
}
case "chat_messages":
if opts.Bodies {
anonymizeChatMessages(*slice.(*[]model.ChatMessage))
}
case "attachments":
if opts.Attachments {
anonymizeAttachments(*slice.(*[]model.Attachment))
}
}
return nil
}
// Package dbimport loads a dump produced by service/dbexport into the
// running database (#459). The intended use is refreshing staging from a
// production export.
//
// Import is full-replace: every table covered by the dump is truncated
// before its rows are inserted. The whole thing runs inside a single
// GORM transaction, so any failure rolls back to the prior state.
//
// Schema tolerance is applied symmetrically with the export side: a
// table present in the dump but missing on the target is skipped (and
// recorded as such in the result Stats); a table the target expects but
// the dump doesn't carry ends up empty after import. The audit log
// (model.ImportExportAudit) is excluded from the full-replace by design
// so the import history on the target survives across refreshes.
package dbimport
import (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"reflect"
"time"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service/dbexport"
"gorm.io/gorm"
)
// Stats summarizes one import. The controller writes a JSON-serialized
// copy into the audit log and renders it on the result page.
type Stats struct {
// Header fields from the dump.
SchemaVersion int `json:"schema_version"`
AppVersion string `json:"app_version"`
ExportedAt time.Time `json:"exported_at"`
ExportedFrom string `json:"exported_from"`
IncludeAttachments bool `json:"include_attachments"`
// RowCounts is the number of rows imported per table.
RowCounts map[string]int `json:"row_counts"`
// SkippedTables: present in the dump but the target schema doesn't
// know about them (e.g. dump is newer than the target's release).
SkippedTables []string `json:"skipped_tables,omitempty"`
// MissingTables: target expects the table but the dump doesn't
// carry it. The target's table is left empty.
MissingTables []string `json:"missing_tables,omitempty"`
// Anonymizers records which scrubbers ran on this import. Empty
// flags mean the operator opted out (and confirmed the PII risk).
Anonymizers AnonymizeOptions `json:"anonymizers"`
}
// localOnlyTables are persisted but never imported — i.e. the running
// instance keeps its own copies across refreshes.
var localOnlyTables = map[string]bool{
"import_export_audits": true,
}
// Import decompresses and loads a dump from r into db. The function is
// transactional: on any error the caller observes the prior state.
//
// If the dump's schema version is newer than what this build supports
// the import refuses up front (no transaction opened). A newer target
// reading an older dump is fine.
//
// anon controls which PII anonymizers run on the way in. The default
// (all-on) is what callers should pass when refreshing staging from
// production; the controller only relaxes flags after the operator
// has typed the risk-acknowledgement string.
func Import(db *gorm.DB, r io.Reader, anon AnonymizeOptions) (*Stats, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
var dump dbexport.Dump
if err := json.NewDecoder(gz).Decode(&dump); err != nil {
return nil, fmt.Errorf("parse dump: %w", err)
}
if dump.Header.SchemaVersion > dbexport.SchemaVersion {
return nil, fmt.Errorf(
"dump schema version %d is newer than this instance supports (%d) — update the target first",
dump.Header.SchemaVersion, dbexport.SchemaVersion)
}
stats := &Stats{
SchemaVersion: dump.Header.SchemaVersion,
AppVersion: dump.Header.AppVersion,
ExportedFrom: dump.Header.ExportedFrom,
IncludeAttachments: dump.Header.IncludeAttachments,
RowCounts: map[string]int{},
Anonymizers: anon,
}
if parsed, err := time.Parse(time.RFC3339, dump.Header.ExportedAt); err == nil {
stats.ExportedAt = parsed
}
// Bcrypt is intentionally slow; do it once per import, not per row.
var ph *preparedHash
if anon.PasswordHashes {
ph = newPreparedHash()
if ph.err != nil {
return nil, fmt.Errorf("prepare staging password hash: %w", ph.err)
}
}
err = db.Transaction(func(tx *gorm.DB) error {
return runImport(tx, &dump, stats, anon, ph)
})
return stats, err
}
func runImport(tx *gorm.DB, dump *dbexport.Dump, stats *Stats, anon AnonymizeOptions, ph *preparedHash) error {
// 1. Wipe every table that this target understands. Order doesn't
// matter because we delete everything; junction rows go first to
// keep things tidy on databases that do enforce FKs at delete
// time (postgres with constraint declarations).
for _, table := range dbexport.JunctionTables {
if !tx.Migrator().HasTable(table) {
continue
}
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
return fmt.Errorf("clear junction %s: %w", table, err)
}
}
for _, spec := range dbexport.ModelTables {
proto := spec.NewSlice()
if !tx.Migrator().HasTable(proto) {
continue
}
// Unscoped to delete soft-deleted rows too.
if err := tx.Unscoped().Where("1 = 1").Delete(proto).Error; err != nil {
return fmt.Errorf("clear %s: %w", spec.Name, err)
}
}
// 2. Load model-backed tables.
for _, spec := range dbexport.ModelTables {
if localOnlyTables[spec.Name] {
continue
}
proto := spec.NewSlice()
hasTable := tx.Migrator().HasTable(proto)
raw, inDump := dump.Tables[spec.Name]
if !inDump && hasTable {
stats.MissingTables = append(stats.MissingTables, spec.Name)
continue
}
if inDump && !hasTable {
stats.SkippedTables = append(stats.SkippedTables, spec.Name)
continue
}
if !inDump && !hasTable {
continue
}
slice := spec.NewSlice()
if err := json.Unmarshal(raw, slice); err != nil {
return fmt.Errorf("parse %s: %w", spec.Name, err)
}
if err := applyAnonymizers(spec.Name, slice, anon, ph); err != nil {
return fmt.Errorf("anonymize %s: %w", spec.Name, err)
}
count := reflect.ValueOf(slice).Elem().Len()
if count == 0 {
continue
}
// CreateInBatches keeps large tables from blowing the SQL parameter
// limit (sqlite caps at ~999 host variables per statement).
if err := tx.Session(&gorm.Session{CreateBatchSize: 100}).Create(slice).Error; err != nil {
return fmt.Errorf("insert %s: %w", spec.Name, err)
}
stats.RowCounts[spec.Name] = count
}
// 3. Load junction tables.
for _, table := range dbexport.JunctionTables {
raw, inDump := dump.Tables[table]
hasTable := tx.Migrator().HasTable(table)
if !inDump && hasTable {
stats.MissingTables = append(stats.MissingTables, table)
continue
}
if inDump && !hasTable {
stats.SkippedTables = append(stats.SkippedTables, table)
continue
}
if !inDump || !hasTable {
continue
}
var rows []map[string]interface{}
if err := json.Unmarshal(raw, &rows); err != nil {
return fmt.Errorf("parse junction %s: %w", table, err)
}
if len(rows) == 0 {
continue
}
// Batch insert to avoid sqlite host-variable limits.
const batch = 100
for i := 0; i < len(rows); i += batch {
end := i + batch
if end > len(rows) {
end = len(rows)
}
if err := tx.Table(table).Create(rows[i:end]).Error; err != nil {
return fmt.Errorf("insert junction %s: %w", table, err)
}
}
stats.RowCounts[table] = len(rows)
}
// 4. Postgres sequence reset. SQLite's rowid handles this implicitly;
// postgres needs each id-sequence advanced past the max imported id
// so subsequent inserts don't collide. No-op on sqlite/mysql.
if tx.Dialector.Name() == "postgres" {
if err := resetPostgresSequences(tx); err != nil {
return fmt.Errorf("reset sequences: %w", err)
}
}
// Audit row is written by the caller, OUTSIDE this transaction, so the
// failure case still produces an audit entry. Keeping that responsibility
// in the controller also keeps the dbimport package free of
// model.ImportExportAudit knowledge beyond the localOnlyTables guard.
_ = model.ImportExportAudit{} // referenced to anchor the contract
return nil
}
func resetPostgresSequences(tx *gorm.DB) error {
for _, spec := range dbexport.ModelTables {
proto := spec.NewSlice()
if !tx.Migrator().HasTable(proto) {
continue
}
// pg_get_serial_sequence returns NULL for tables without a serial
// id column, so the COALESCE keeps setval safe in that case.
q := fmt.Sprintf(
`SELECT setval(pg_get_serial_sequence('%s', 'id'),
COALESCE((SELECT MAX(id) FROM %s), 1),
(SELECT MAX(id) IS NOT NULL FROM %s))
WHERE pg_get_serial_sequence('%s', 'id') IS NOT NULL`,
spec.Name, spec.Name, spec.Name, spec.Name)
if err := tx.Exec(q).Error; err != nil {
return fmt.Errorf("setval %s: %w", spec.Name, err)
}
}
return nil
}
package service
import (
"fmt"
"os"
"time"
"wippidu_app_backend/internal/email"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gopkg.in/gomail.v2"
)
const (
maxRetries = 3
initialDelay = 1 * time.Second
maxDelay = 10 * time.Second
appName = "Wippidu"
)
// SendTestEmail sends a test email to verify SMTP configuration
func SendTestEmail(to, lang string) error {
settings, err := model.GetEmailSettings()
if err != nil {
return fmt.Errorf("failed to get email settings: %w", err)
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
// Dev mode: SMTP not configured. Use structured logging so the
// recipient address is in a typed field and downstream log
// scrubbers can mask it consistently (audit #464).
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "test",
"to", to,
"lang", lang)
return nil
}
data := email.TemplateData{
AppName: appName,
Lang: lang,
}
content, err := email.RenderTestEmail(data)
if err != nil {
return fmt.Errorf("failed to render test email: %w", err)
}
return sendEmailWithRetry(settings, to, content)
}
// SendActivationEmail sends an activation email to a user
func SendActivationEmail(toEmail, activationCode, firstName, lang string) error {
settings, err := model.GetEmailSettings()
if err != nil {
return fmt.Errorf("failed to get email settings: %w", err)
}
// Build activation URL
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
// Check if we need to add https
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
activationURL := fmt.Sprintf("%s://%s/activate/%s", scheme, baseURL, activationCode)
data := email.TemplateData{
AppName: appName,
FirstName: firstName,
ActivationURL: activationURL,
ActivationCode: activationCode,
Lang: lang,
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "activation",
"to", toEmail,
"firstName", firstName,
"activationURL", activationURL,
"lang", lang)
return nil
}
content, err := email.RenderActivationEmail(data)
if err != nil {
return fmt.Errorf("failed to render activation email: %w", err)
}
return sendEmailWithRetry(settings, toEmail, content)
}
// SendEmailChangeVerification sends a verification email for email address change
func SendEmailChangeVerification(toEmail, firstName, newEmail, code, lang string) error {
settings, err := model.GetEmailSettings()
if err != nil {
return fmt.Errorf("failed to get email settings: %w", err)
}
// Build verification URL
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
verificationURL := fmt.Sprintf("%s://%s/%s/settings/email/confirm/%s", scheme, baseURL, lang, code)
data := email.TemplateData{
AppName: appName,
FirstName: firstName,
NewEmail: newEmail,
VerificationURL: verificationURL,
Lang: lang,
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "email_change_verification",
"to", toEmail,
"firstName", firstName,
"newEmail", newEmail,
"verificationURL", verificationURL,
"lang", lang)
return nil
}
content, err := email.RenderEmailChangeEmail(data)
if err != nil {
return fmt.Errorf("failed to render email change email: %w", err)
}
return sendEmailWithRetry(settings, toEmail, content)
}
// SendReviewRequestEmail sends a review request email to a reviewer
func SendReviewRequestEmail(toEmail, firstName, letterSubject, letterID, lang string) error {
settings, err := model.GetEmailSettings()
if err != nil {
return fmt.Errorf("failed to get email settings: %w", err)
}
// Build review URL
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
reviewURL := fmt.Sprintf("%s://%s/%s/parentalletters/%s", scheme, baseURL, lang, letterID)
data := email.TemplateData{
AppName: appName,
FirstName: firstName,
LetterSubject: letterSubject,
ReviewURL: reviewURL,
Lang: lang,
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
// Dev mode: log the email
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "review_request",
"to", toEmail,
"firstName", firstName,
"letterSubject", letterSubject,
"reviewURL", reviewURL,
"lang", lang)
return nil
}
content, err := email.RenderReviewRequestEmail(data)
if err != nil {
return fmt.Errorf("failed to render review request email: %w", err)
}
return sendEmailWithRetry(settings, toEmail, content)
}
// SendRegistrationRejectionNotification sends a rejection email to a user
func SendRegistrationRejectionNotification(toEmail, firstName, reason, lang string) error {
settings, err := model.GetEmailSettings()
if err != nil {
return fmt.Errorf("failed to get email settings: %w", err)
}
data := email.TemplateData{
AppName: appName,
FirstName: firstName,
Reason: reason,
Lang: lang,
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "registration_rejection",
"to", toEmail,
"firstName", firstName,
"reason", reason,
"lang", lang)
return nil
}
content, err := email.RenderRejectionEmail(data)
if err != nil {
return fmt.Errorf("failed to render rejection email: %w", err)
}
return sendEmailWithRetry(settings, toEmail, content)
}
// sendEmailWithRetry sends an email with retry logic
func sendEmailWithRetry(settings *model.EmailSettings, to string, content *email.EmailContent) error {
var lastErr error
delay := initialDelay
for attempt := 1; attempt <= maxRetries; attempt++ {
err := sendEmail(settings, to, content)
if err == nil {
logger.Info("email sent", "to", to, "attempt", attempt)
return nil
}
lastErr = err
logger.Warn("email send attempt failed",
"to", to,
"attempt", attempt,
"maxRetries", maxRetries,
"error", err)
if attempt < maxRetries {
time.Sleep(delay)
if delay*2 <= maxDelay {
delay = delay * 2
} else {
delay = maxDelay
}
}
}
logger.Error("email send exhausted retries",
"to", to,
"attempts", maxRetries,
"error", lastErr)
return lastErr
}
// sendEmail sends an email using gomail
func sendEmail(settings *model.EmailSettings, to string, content *email.EmailContent) error {
m := gomail.NewMessage()
m.SetHeader("From", settings.SMTPFrom)
m.SetHeader("To", to)
m.SetHeader("Subject", content.Subject)
m.SetBody("text/plain", content.PlainBody)
m.AddAlternative("text/html", content.HTMLBody)
d := gomail.NewDialer(settings.SMTPHost, settings.SMTPPort, settings.SMTPUser, settings.SMTPPassword)
// Configure TLS
if !settings.SMTPUseTLS {
d.SSL = false
d.TLSConfig = nil
}
return d.DialAndSend(m)
}
// EmailService defines the interface for sending emails
// Kept for backwards compatibility with existing code
type EmailService interface {
SendActivationEmail(email, activationCode, firstName string) error
SendRegistrationApprovalNotification(email, firstName string) error
SendRegistrationRejectionNotification(email, firstName, reason string) error
}
// StubEmailService is a no-op implementation of EmailService
// Deprecated: Use the standalone functions instead
type StubEmailService struct{}
// NewStubEmailService creates a new stub email service
// Deprecated: Use the standalone functions instead
func NewStubEmailService() *StubEmailService {
return &StubEmailService{}
}
// SendActivationEmail does nothing in the stub implementation
func (s *StubEmailService) SendActivationEmail(email, activationCode, firstName string) error {
return SendActivationEmail(email, activationCode, firstName, "de")
}
// SendRegistrationApprovalNotification does nothing in the stub implementation
func (s *StubEmailService) SendRegistrationApprovalNotification(email, firstName string) error {
// This is the same as activation email
return nil
}
// SendRegistrationRejectionNotification does nothing in the stub implementation
func (s *StubEmailService) SendRegistrationRejectionNotification(email, firstName, reason string) error {
return SendRegistrationRejectionNotification(email, firstName, reason, "de")
}
package service
import (
"fmt"
"os"
"unicode/utf8"
"wippidu_app_backend/internal/email"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// SendPublishNotificationEmails sends notification emails to all opted-in recipients.
// This should be called in a goroutine to avoid blocking the request.
func SendPublishNotificationEmails(itemType, title, preview, url string, recipients []model.User) {
settings, err := model.GetEmailSettings()
if err != nil {
logger.Error("email notification: failed to get email settings", "error", err)
return
}
opted := filterOptedInRecipients(recipients)
if len(opted) == 0 {
return
}
for _, user := range opted {
firstName := user.FirstName
if firstName == "" {
firstName = user.Email
}
lang := user.Language
if lang == "" {
lang = "de"
}
data := email.TemplateData{
AppName: appName,
FirstName: firstName,
Lang: lang,
ItemType: itemType,
ItemTitle: title,
ItemPreview: preview,
ItemURL: url,
}
if settings == nil || !settings.Enabled || settings.SMTPHost == "" {
logger.Info("dev-mode email skipped (SMTP not configured)",
"kind", "notification",
"itemType", itemType,
"to", user.Email,
"title", title)
continue
}
content, err := email.RenderNotificationEmail(data)
if err != nil {
logger.Error("email notification: render failed",
"to", user.Email,
"itemType", itemType,
"error", err)
continue
}
if err := sendEmailWithRetry(settings, user.Email, content); err != nil {
logger.Error("email notification: send failed",
"to", user.Email,
"itemType", itemType,
"error", err)
}
}
}
// NotifyNewsPublished sends email notifications for a newly published news item.
// Collects parent recipients via GetNewsRecipients and employee recipients by scope,
// deduplicates, and sends asynchronously.
func NotifyNewsPublished(db *gorm.DB, news *model.News) {
// Get parent recipients
parentRecipients, err := GetNewsRecipients(db, news)
if err != nil {
logger.Error("email notification: failed to load news parent recipients",
"newsID", news.ID,
"error", err)
parentRecipients = []model.User{}
}
// Get employee recipients based on news scope
employeeRecipients, err := GetNewsEmployeeRecipients(db, news)
if err != nil {
logger.Error("email notification: failed to load news employee recipients",
"newsID", news.ID,
"error", err)
employeeRecipients = []model.User{}
}
// Merge and deduplicate
allRecipients := deduplicateUsers(append(parentRecipients, employeeRecipients...))
preview := truncateText(news.Text, 200)
url := buildItemURL("news", news.ID)
go SendPublishNotificationEmails("news", news.Title, preview, url, allRecipients)
}
// NotifyLetterPublished sends email notifications for a newly published parental letter.
func NotifyLetterPublished(db *gorm.DB, letter *model.ParentalLetter) {
recipients, err := GetLetterRecipients(db, letter)
if err != nil {
logger.Error("email notification: failed to load letter recipients",
"letterID", letter.ID,
"error", err)
return
}
preview := truncateText(letter.Text, 200)
url := buildItemURL("letter", letter.ID)
go SendPublishNotificationEmails("letter", letter.Subject, preview, url, recipients)
}
// NotifyMessagePublished sends email notifications for a newly published message.
func NotifyMessagePublished(db *gorm.DB, message *model.Message) {
// Load recipients if not already loaded
if len(message.Recipients) == 0 {
var msg model.Message
if err := db.Preload("Recipients").First(&msg, message.ID).Error; err != nil {
logger.Error("email notification: failed to load message recipients",
"messageID", message.ID,
"error", err)
return
}
message.Recipients = msg.Recipients
}
preview := truncateText(message.Text, 200)
url := buildItemURL("message", message.ID)
go SendPublishNotificationEmails("message", message.Subject, preview, url, message.Recipients)
}
// GetNewsEmployeeRecipients returns employees who should receive notification for a news item
// based on its scope (group, location, or global).
func GetNewsEmployeeRecipients(db *gorm.DB, news *model.News) ([]model.User, error) {
var userIDs []uint
if news.GroupId != nil && *news.GroupId > 0 {
// Group-scoped: get employees assigned to this group
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead"}).
Where("group_teachers.group_id = ?", *news.GroupId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get group employee recipients: %w", err)
}
} else if news.LocationId != nil && *news.LocationId > 0 {
// Location-scoped: get employees at this location
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Joins("JOIN groups ON groups.id = group_teachers.group_id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead"}).
Where("groups.location_id = ?", *news.LocationId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get location employee recipients: %w", err)
}
} else {
// Global news: get all employees
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get global employee recipients: %w", err)
}
}
if len(userIDs) == 0 {
return []model.User{}, nil
}
var recipients []model.User
err := db.Where("id IN ?", userIDs).Find(&recipients).Error
if err != nil {
return nil, fmt.Errorf("failed to load employee recipients: %w", err)
}
return recipients, nil
}
// filterOptedInRecipients returns only users who have opted in to email notifications.
func filterOptedInRecipients(users []model.User) []model.User {
var opted []model.User
for _, u := range users {
if u.NotifyByEmail {
opted = append(opted, u)
}
}
return opted
}
// deduplicateUsers removes duplicate users based on user ID.
func deduplicateUsers(users []model.User) []model.User {
seen := make(map[uint]bool)
var result []model.User
for _, u := range users {
if !seen[u.ID] {
seen[u.ID] = true
result = append(result, u)
}
}
return result
}
// truncateText truncates text to the specified number of runes, appending "..." if truncated.
func truncateText(text string, maxRunes int) string {
if utf8.RuneCountInString(text) <= maxRunes {
return text
}
runes := []rune(text)
return string(runes[:maxRunes]) + "..."
}
// buildItemURL constructs the URL for viewing an item in Wippidu.
func buildItemURL(itemType string, itemID uint) string {
baseURL := os.Getenv("EXPECTED_HOST")
if baseURL == "" {
baseURL = "localhost:8080"
}
scheme := "https"
if os.Getenv("USE_TLS") == "false" {
scheme = "http"
}
switch itemType {
case "news":
return fmt.Sprintf("%s://%s/de/news/%d", scheme, baseURL, itemID)
case "letter":
return fmt.Sprintf("%s://%s/de/parentalletters/%d", scheme, baseURL, itemID)
case "message":
return fmt.Sprintf("%s://%s/de/parent/messages/%d", scheme, baseURL, itemID)
default:
return fmt.Sprintf("%s://%s/de/", scheme, baseURL)
}
}
package service
import (
"fmt"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// GetOrCreateDefaultChat gets or creates the default chat for a location
// The default chat includes all employees at the location
func GetOrCreateDefaultChat(db *gorm.DB, locationID uint) (*model.EmployeeChat, error) {
var chat model.EmployeeChat
// Look for existing default chat
err := db.Where("location_id = ? AND is_default = ?", locationID, true).
Preload("Location").
First(&chat).Error
if err == nil {
return &chat, nil
}
if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("failed to check for existing default chat: %w", err)
}
// Get location for naming
var location model.Location
if err := db.First(&location, locationID).Error; err != nil {
return nil, fmt.Errorf("failed to get location: %w", err)
}
// Create default chat
chat = model.EmployeeChat{
Name: location.Name,
LocationId: locationID,
IsDefault: true,
}
if err := db.Create(&chat).Error; err != nil {
return nil, fmt.Errorf("failed to create default chat: %w", err)
}
// Add all location employees as participants
employees, err := GetEmployeesForLocation(db, locationID)
if err != nil {
return nil, fmt.Errorf("failed to get employees: %w", err)
}
if len(employees) > 0 {
if err := db.Model(&chat).Association("Participants").Append(employees); err != nil {
return nil, fmt.Errorf("failed to add participants: %w", err)
}
}
chat.Location = &location
return &chat, nil
}
// GetChatsForUser returns all chats accessible to a user
// For admins, adminLocationID determines which location's chats to show
func GetChatsForUser(db *gorm.DB, user *model.User, adminLocationID uint) ([]model.EmployeeChat, error) {
var chats []model.EmployeeChat
// Admin viewing a specific location
if user.IsAdmin() && adminLocationID > 0 {
err := db.Where("location_id = ?", adminLocationID).
Preload("Location").
Preload("Participants").
Order("is_default DESC, name ASC").
Find(&chats).Error
if err != nil {
return nil, fmt.Errorf("failed to get chats for admin: %w", err)
}
return chats, nil
}
// Get user's location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil {
return nil, fmt.Errorf("failed to get user locations: %w", err)
}
if len(locationIDs) == 0 {
return []model.EmployeeChat{}, nil
}
// Location leads can see all chats at their locations
if user.IsHouseLeader() {
err := db.Where("location_id IN ?", locationIDs).
Preload("Location").
Preload("Participants").
Order("is_default DESC, name ASC").
Find(&chats).Error
if err != nil {
return nil, fmt.Errorf("failed to get chats for location lead: %w", err)
}
return chats, nil
}
// Regular employees see only chats where they are participants
err = db.Joins("JOIN employee_chat_participants ON employee_chat_participants.employee_chat_id = employee_chats.id").
Where("employee_chat_participants.user_id = ?", user.ID).
Where("employee_chats.location_id IN ?", locationIDs).
Preload("Location").
Preload("Participants").
Order("is_default DESC, name ASC").
Find(&chats).Error
if err != nil {
return nil, fmt.Errorf("failed to get chats for employee: %w", err)
}
return chats, nil
}
// CanUserAccessChat checks if a user can access a specific chat
func CanUserAccessChat(db *gorm.DB, user *model.User, chatID uint, adminLocationID uint) bool {
var chat model.EmployeeChat
if err := db.First(&chat, chatID).Error; err != nil {
return false
}
// Admin with matching location selection
if user.IsAdmin() && adminLocationID > 0 {
return chat.LocationId == adminLocationID
}
// Get user's location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return false
}
// Check if chat is in user's locations
chatInUserLocation := false
for _, locID := range locationIDs {
if chat.LocationId == locID {
chatInUserLocation = true
break
}
}
if !chatInUserLocation {
return false
}
// Location leads can access all chats at their location
if user.IsHouseLeader() {
return true
}
// Check if user is a participant
var count int64
db.Table("employee_chat_participants").
Where("employee_chat_id = ? AND user_id = ?", chatID, user.ID).
Count(&count)
return count > 0
}
// GetChatMessages returns paginated messages for a chat
// Messages are returned in reverse chronological order (newest first)
// Use beforeID to load older messages
func GetChatMessages(db *gorm.DB, chatID uint, limit int, beforeID uint) ([]model.ChatMessage, error) {
if limit <= 0 {
limit = 10
}
query := db.Where("chat_id = ?", chatID)
if beforeID > 0 {
query = query.Where("id < ?", beforeID)
}
var messages []model.ChatMessage
err := query.Order("id DESC").
Limit(limit).
Preload("Sender").
Preload("Sender.Roles").
Find(&messages).Error
if err != nil {
return nil, fmt.Errorf("failed to get chat messages: %w", err)
}
return messages, nil
}
// SendChatMessage creates a new message in a chat
func SendChatMessage(db *gorm.DB, chatID uint, senderID uint, text string) (*model.ChatMessage, error) {
if text == "" {
return nil, fmt.Errorf("message text cannot be empty")
}
message := model.ChatMessage{
ChatId: chatID,
SenderId: senderID,
Text: text,
}
if err := db.Create(&message).Error; err != nil {
return nil, fmt.Errorf("failed to create message: %w", err)
}
// Preload sender for return
db.Preload("Sender").Preload("Sender.Roles").First(&message, message.ID)
return &message, nil
}
// MarkChatMessagesAsRead marks all messages in a chat as read by a user
// Excludes the user's own messages
func MarkChatMessagesAsRead(db *gorm.DB, chatID uint, userID uint) error {
// Get all unread messages in this chat (excluding user's own)
var messages []model.ChatMessage
err := db.Where("chat_id = ? AND sender_id != ?", chatID, userID).
Find(&messages).Error
if err != nil {
return fmt.Errorf("failed to get messages: %w", err)
}
now := time.Now()
for _, msg := range messages {
// Check if already read
var existing model.ChatMessageRead
err := db.Where("message_id = ? AND user_id = ?", msg.ID, userID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Create read record
readRecord := model.ChatMessageRead{
MessageId: msg.ID,
UserId: userID,
ReadAt: &now,
}
if createErr := db.Create(&readRecord).Error; createErr != nil {
return fmt.Errorf("failed to create read record: %w", createErr)
}
} else if err != nil {
return fmt.Errorf("failed to check read status: %w", err)
} else if existing.ReadAt == nil {
// Update existing record
existing.ReadAt = &now
if saveErr := db.Save(&existing).Error; saveErr != nil {
return fmt.Errorf("failed to update read record: %w", saveErr)
}
}
}
return nil
}
// CreateChat creates a new custom chat for a location
func CreateChat(db *gorm.DB, name string, locationID uint, creatorID uint, participantIDs []uint) (*model.EmployeeChat, error) {
if name == "" {
return nil, fmt.Errorf("chat name cannot be empty")
}
chat := model.EmployeeChat{
Name: name,
LocationId: locationID,
CreatedById: &creatorID,
IsDefault: false,
}
if err := db.Create(&chat).Error; err != nil {
return nil, fmt.Errorf("failed to create chat: %w", err)
}
// Add participants
if len(participantIDs) > 0 {
var participants []model.User
if err := db.Where("id IN ?", participantIDs).Find(&participants).Error; err != nil {
return nil, fmt.Errorf("failed to get participants: %w", err)
}
if err := db.Model(&chat).Association("Participants").Append(participants); err != nil {
return nil, fmt.Errorf("failed to add participants: %w", err)
}
}
// Preload for return
db.Preload("Location").Preload("Participants").First(&chat, chat.ID)
return &chat, nil
}
// GetEmployeesForLocation returns all employees at a location
func GetEmployeesForLocation(db *gorm.DB, locationID uint) ([]model.User, error) {
var users []model.User
// Get all groups at the location
var groupIDs []uint
err := db.Model(&model.Group{}).
Where("location_id = ?", locationID).
Pluck("id", &groupIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get groups: %w", err)
}
if len(groupIDs) == 0 {
return []model.User{}, nil
}
// Get all employees assigned to these groups
err = db.Distinct().
Joins("JOIN group_teachers ON group_teachers.user_id = users.id").
Where("group_teachers.group_id IN ?", groupIDs).
Where("users.deleted_at IS NULL").
Find(&users).Error
if err != nil {
return nil, fmt.Errorf("failed to get employees: %w", err)
}
// Also include location leads
var location model.Location
if err := db.Preload("Lead").Preload("Lead2nd").First(&location, locationID).Error; err == nil {
leadMap := make(map[uint]bool)
for _, u := range users {
leadMap[u.ID] = true
}
if location.Lead != nil && !leadMap[location.Lead.ID] {
users = append(users, *location.Lead)
}
if location.Lead2nd != nil && !leadMap[location.Lead2nd.ID] {
users = append(users, *location.Lead2nd)
}
}
return users, nil
}
// GetGroupsForLocation returns all groups at a location (for quick-select in chat creation)
func GetGroupsForLocation(db *gorm.DB, locationID uint) ([]model.Group, error) {
var groups []model.Group
err := db.Where("location_id = ?", locationID).
Preload("Teachers").
Order("name ASC").
Find(&groups).Error
if err != nil {
return nil, fmt.Errorf("failed to get groups: %w", err)
}
return groups, nil
}
// GetChatWithUnreadCount returns a chat with its unread message count for a user
type ChatWithUnread struct {
Chat model.EmployeeChat
UnreadCount int
}
// GetChatsWithUnreadCounts returns chats with unread counts for a user
func GetChatsWithUnreadCounts(db *gorm.DB, user *model.User, adminLocationID uint) ([]ChatWithUnread, error) {
chats, err := GetChatsForUser(db, user, adminLocationID)
if err != nil {
return nil, err
}
result := make([]ChatWithUnread, len(chats))
for i, chat := range chats {
count := getUnreadCountForChat(db, chat.ID, user.ID)
result[i] = ChatWithUnread{
Chat: chat,
UnreadCount: count,
}
}
return result, nil
}
// getUnreadCountForChat counts unread messages in a chat for a user
func getUnreadCountForChat(db *gorm.DB, chatID uint, userID uint) int {
var count int64
// Count messages in chat that:
// 1. Were not sent by this user
// 2. Do not have a read record for this user
db.Table("chat_messages").
Where("chat_id = ?", chatID).
Where("sender_id != ?", userID).
Where("deleted_at IS NULL").
Where("NOT EXISTS (SELECT 1 FROM chat_message_reads WHERE chat_message_reads.message_id = chat_messages.id AND chat_message_reads.user_id = ? AND chat_message_reads.read_at IS NOT NULL)", userID).
Count(&count)
return int(count)
}
// EnsureUserInChat checks whether the user is already a participant in the
// given chat and adds them if not. Returns true when the user was newly added.
func EnsureUserInChat(db *gorm.DB, chatID uint, userID uint) (bool, error) {
var count int64
if err := db.Table("employee_chat_participants").
Where("employee_chat_id = ? AND user_id = ?", chatID, userID).
Count(&count).Error; err != nil {
return false, fmt.Errorf("failed to check chat participation: %w", err)
}
if count > 0 {
return false, nil
}
var user model.User
if err := db.First(&user, userID).Error; err != nil {
return false, fmt.Errorf("failed to get user: %w", err)
}
var chat model.EmployeeChat
if err := db.First(&chat, chatID).Error; err != nil {
return false, fmt.Errorf("failed to get chat: %w", err)
}
if err := db.Model(&chat).Association("Participants").Append(&user); err != nil {
return false, fmt.Errorf("failed to add participant: %w", err)
}
return true, nil
}
// AddUserToDefaultChats adds a user to all default chats at their locations
// Called when a new employee is approved or assigned to a location
func AddUserToDefaultChats(db *gorm.DB, userID uint) error {
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
locationIDs, err := user.GetLocationIDs(db)
if err != nil {
return fmt.Errorf("failed to get user locations: %w", err)
}
for _, locID := range locationIDs {
chat, err := GetOrCreateDefaultChat(db, locID)
if err != nil {
continue // Skip on error, don't fail completely
}
if _, err := EnsureUserInChat(db, chat.ID, userID); err != nil {
continue
}
}
return nil
}
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
const (
// EmployeeInvitationCodeLength is the number of random bytes (will be 32 hex chars)
EmployeeInvitationCodeLength = 16
// EmployeeInvitationExpiryDays is the default expiration period for employee invitation codes
EmployeeInvitationExpiryDays = 30
)
// UnregisteredEmployee represents a SyncEmployee that doesn't have a User account
type UnregisteredEmployee struct {
model.SyncEmployee
HasActiveCode bool // True if there's already an active invitation code
}
// EmployeeInvitationService handles employee invitation code operations
type EmployeeInvitationService struct {
db *gorm.DB
}
// NewEmployeeInvitationService creates a new employee invitation service
func NewEmployeeInvitationService(db *gorm.DB) *EmployeeInvitationService {
return &EmployeeInvitationService{db: db}
}
// GetUnregisteredEmployees returns SyncEmployees that don't have linked User accounts
func (s *EmployeeInvitationService) GetUnregisteredEmployees() ([]UnregisteredEmployee, error) {
var syncEmployees []model.SyncEmployee
// Find SyncEmployees where no User has the same ExternalID
err := s.db.Raw(`
SELECT se.* FROM sync_employees se
LEFT JOIN users u ON se.external_id = u.external_id AND u.deleted_at IS NULL
WHERE u.id IS NULL
ORDER BY se.nachname, se.vorname
`).Scan(&syncEmployees).Error
if err != nil {
return nil, err
}
// Check which employees have active invitation codes
var activeCodeEmployeeIDs []uint
s.db.Model(&model.EmployeeInvitationCode{}).
Where("used_at IS NULL AND revoked_at IS NULL AND expires_at > ?", time.Now()).
Pluck("sync_employee_id", &activeCodeEmployeeIDs)
activeCodeMap := make(map[uint]bool)
for _, id := range activeCodeEmployeeIDs {
activeCodeMap[id] = true
}
result := make([]UnregisteredEmployee, len(syncEmployees))
for i, emp := range syncEmployees {
result[i] = UnregisteredEmployee{
SyncEmployee: emp,
HasActiveCode: activeCodeMap[emp.ID],
}
}
return result, nil
}
// GenerateEmployeeInvitationCode creates a new invitation code for an employee
func (s *EmployeeInvitationService) GenerateEmployeeInvitationCode(syncEmployeeID uint, createdByID uint) (*model.EmployeeInvitationCode, error) {
// Verify the SyncEmployee exists
var syncEmployee model.SyncEmployee
if err := s.db.First(&syncEmployee, syncEmployeeID).Error; err != nil {
return nil, errors.New("employee not found in sync data")
}
// Check if employee already has a User account
var existingUser model.User
if err := s.db.Where("external_id = ?", syncEmployee.ExternalID).First(&existingUser).Error; err == nil {
return nil, errors.New("employee already has a user account")
}
// Generate secure random code
code, err := generateEmployeeSecureCode()
if err != nil {
return nil, err
}
invitation := &model.EmployeeInvitationCode{
Code: code,
SyncEmployeeID: syncEmployeeID,
ExpiresAt: time.Now().AddDate(0, 0, EmployeeInvitationExpiryDays),
CreatedByID: createdByID,
}
if err := s.db.Create(invitation).Error; err != nil {
return nil, err
}
// Preload data for display
s.db.Preload("SyncEmployee").Preload("CreatedBy").First(invitation, invitation.ID)
return invitation, nil
}
// GenerateBulkEmployeeInvitations creates invitation codes for multiple employees
func (s *EmployeeInvitationService) GenerateBulkEmployeeInvitations(syncEmployeeIDs []uint, createdByID uint) ([]*model.EmployeeInvitationCode, error) {
var invitations []*model.EmployeeInvitationCode
err := s.db.Transaction(func(tx *gorm.DB) error {
for _, syncEmployeeID := range syncEmployeeIDs {
// Verify the SyncEmployee exists
var syncEmployee model.SyncEmployee
if err := tx.First(&syncEmployee, syncEmployeeID).Error; err != nil {
continue // Skip if employee not found
}
// Check if employee already has a User account
var existingUser model.User
if err := tx.Where("external_id = ?", syncEmployee.ExternalID).First(&existingUser).Error; err == nil {
continue // Skip if user already exists
}
// Generate secure random code
code, err := generateEmployeeSecureCode()
if err != nil {
return err
}
invitation := &model.EmployeeInvitationCode{
Code: code,
SyncEmployeeID: syncEmployeeID,
ExpiresAt: time.Now().AddDate(0, 0, EmployeeInvitationExpiryDays),
CreatedByID: createdByID,
}
if err := tx.Create(invitation).Error; err != nil {
return err
}
invitations = append(invitations, invitation)
}
return nil
})
if err != nil {
return nil, err
}
if len(invitations) == 0 {
return invitations, nil
}
// Preload all related data for display
var invitationIDs []uint
for _, inv := range invitations {
invitationIDs = append(invitationIDs, inv.ID)
}
var preloadedInvitations []*model.EmployeeInvitationCode
s.db.Preload("SyncEmployee").
Where("id IN ?", invitationIDs).
Order("id ASC").
Find(&preloadedInvitations)
return preloadedInvitations, nil
}
// GetEmployeeInvitationByCode retrieves an employee invitation by its code
func (s *EmployeeInvitationService) GetEmployeeInvitationByCode(code string) (*model.EmployeeInvitationCode, error) {
var invitation model.EmployeeInvitationCode
err := s.db.Preload("SyncEmployee").
Preload("CreatedBy").
Where("code = ?", code).First(&invitation).Error
if err != nil {
return nil, err
}
return &invitation, nil
}
// GetEmployeeInvitationsByIDs retrieves employee invitations by their IDs
func (s *EmployeeInvitationService) GetEmployeeInvitationsByIDs(ids []uint) ([]*model.EmployeeInvitationCode, error) {
var invitations []*model.EmployeeInvitationCode
err := s.db.Preload("SyncEmployee").
Where("id IN ?", ids).
Order("id ASC").
Find(&invitations).Error
return invitations, err
}
// UseEmployeeInvitationCode marks the code as used
func (s *EmployeeInvitationService) UseEmployeeInvitationCode(code string, email string) error {
var invitation model.EmployeeInvitationCode
if err := s.db.Where("code = ?", code).First(&invitation).Error; err != nil {
return errors.New("employee invitation code not found")
}
if !invitation.IsValid() {
if invitation.IsUsed() {
return errors.New("employee invitation code has already been used")
}
if invitation.IsRevoked() {
return errors.New("employee invitation code has been revoked")
}
if invitation.IsExpired() {
return errors.New("employee invitation code has expired")
}
return errors.New("employee invitation code is not valid")
}
now := time.Now()
normalizedEmail := strings.ToLower(strings.TrimSpace(email))
invitation.UsedAt = &now
invitation.UsedByEmail = &normalizedEmail
return s.db.Save(&invitation).Error
}
// RevokeEmployeeInvitationCode revokes an employee invitation code
func (s *EmployeeInvitationService) RevokeEmployeeInvitationCode(codeID uint, revokedByID uint) error {
var invitation model.EmployeeInvitationCode
if err := s.db.First(&invitation, codeID).Error; err != nil {
return err
}
if invitation.UsedAt != nil {
return errors.New("cannot revoke a used invitation code")
}
if invitation.RevokedAt != nil {
return errors.New("invitation code is already revoked")
}
now := time.Now()
invitation.RevokedAt = &now
invitation.RevokedByID = &revokedByID
return s.db.Save(&invitation).Error
}
// GetActiveEmployeeInvitations returns all active (unused, non-revoked, non-expired) employee invitation codes
func (s *EmployeeInvitationService) GetActiveEmployeeInvitations() ([]*model.EmployeeInvitationCode, error) {
var invitations []*model.EmployeeInvitationCode
err := s.db.Preload("SyncEmployee").
Where("used_at IS NULL AND revoked_at IS NULL AND expires_at > ?", time.Now()).
Order("created_at DESC").
Find(&invitations).Error
return invitations, err
}
// generateEmployeeSecureCode generates a cryptographically secure random code
func generateEmployeeSecureCode() (string, error) {
bytes := make([]byte, EmployeeInvitationCodeLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
package service
import (
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// EnrollmentStatus constants match the intranet's Belegung status values
const (
EnrollmentStatusPlanning = 2 // Future/planned enrollment
EnrollmentStatusActive = 3 // Current active contract
)
// GetCurrentEnrollment returns the active enrollment for a child based on today's date.
// An enrollment is considered current if:
// - Status is Active (3)
// - ValidFrom is NULL or <= today
// - ValidUntil is NULL or >= today
// Returns nil if no current enrollment exists.
func GetCurrentEnrollment(db *gorm.DB, childID uint) (*model.Enrollment, error) {
now := time.Now()
var enrollment model.Enrollment
err := db.Preload("Group").
Where("child_id = ?", childID).
Where("status = ?", EnrollmentStatusActive).
Where("(valid_from IS NULL OR valid_from <= ?)", now).
Where("(valid_until IS NULL OR valid_until >= ?)", now).
Order("valid_from DESC"). // Most recent first if multiple match
First(&enrollment).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &enrollment, nil
}
// GetEnrollmentsForChild returns all enrollments for a child, ordered by valid_from descending.
// This includes historical, current, and planned enrollments.
func GetEnrollmentsForChild(db *gorm.DB, childID uint) ([]model.Enrollment, error) {
var enrollments []model.Enrollment
err := db.Preload("Group").Preload("Group.Location").
Where("child_id = ?", childID).
Order("valid_from DESC").
Find(&enrollments).Error
if err != nil {
return nil, err
}
return enrollments, nil
}
// UpdateChildGroupFromEnrollments recalculates a child's GroupId, ValidFrom, and ValidUntil
// based on their current active enrollment.
// This should be called after enrollments are created/updated to keep the cached fields in sync.
func UpdateChildGroupFromEnrollments(db *gorm.DB, childID uint) error {
enrollment, err := GetCurrentEnrollment(db, childID)
if err != nil {
return err
}
updates := map[string]interface{}{
"group_id": nil,
"valid_from": nil,
"valid_until": nil,
}
if enrollment != nil {
updates["group_id"] = enrollment.GroupID
updates["valid_from"] = enrollment.ValidFrom
updates["valid_until"] = enrollment.ValidUntil
}
return db.Model(&model.Child{}).Where("id = ?", childID).Updates(updates).Error
}
// CreateOrUpdateEnrollmentFromSync creates or updates an enrollment record from sync data.
// It uses the external IDs (IDKrp or IDUe3) to identify existing records.
func CreateOrUpdateEnrollmentFromSync(db *gorm.DB, childID, groupID uint, syncData SyncEnrollmentData) (*model.Enrollment, error) {
var enrollment model.Enrollment
var err error
// Try to find existing enrollment by external ID
if syncData.ExternalIDKrp != nil && *syncData.ExternalIDKrp != "" {
err = db.Where("external_id_krp = ?", *syncData.ExternalIDKrp).First(&enrollment).Error
} else if syncData.ExternalIDUe3 != nil && *syncData.ExternalIDUe3 != "" {
err = db.Where("external_id_ue3 = ?", *syncData.ExternalIDUe3).First(&enrollment).Error
} else {
// No external ID - try to find by child, group, and date range
query := db.Where("child_id = ? AND group_id = ?", childID, groupID)
if syncData.ValidFrom != nil {
query = query.Where("valid_from = ?", syncData.ValidFrom)
} else {
query = query.Where("valid_from IS NULL")
}
err = query.First(&enrollment).Error
}
now := time.Now()
if err == gorm.ErrRecordNotFound {
// Create new enrollment
enrollment = model.Enrollment{
ChildID: childID,
GroupID: groupID,
ValidFrom: syncData.ValidFrom,
ValidUntil: syncData.ValidUntil,
Status: syncData.Status,
CareDaysBinary: syncData.CareDaysBinary,
CareDaysCount: syncData.CareDaysCount,
ExternalIDKrp: syncData.ExternalIDKrp,
ExternalIDUe3: syncData.ExternalIDUe3,
Comments: syncData.Comments,
SyncedAt: now,
}
if err := db.Create(&enrollment).Error; err != nil {
return nil, err
}
return &enrollment, nil
}
if err != nil {
return nil, err
}
// Update existing enrollment
enrollment.ChildID = childID
enrollment.GroupID = groupID
enrollment.ValidFrom = syncData.ValidFrom
enrollment.ValidUntil = syncData.ValidUntil
enrollment.Status = syncData.Status
enrollment.CareDaysBinary = syncData.CareDaysBinary
enrollment.CareDaysCount = syncData.CareDaysCount
enrollment.ExternalIDKrp = syncData.ExternalIDKrp
enrollment.ExternalIDUe3 = syncData.ExternalIDUe3
enrollment.Comments = syncData.Comments
enrollment.SyncedAt = now
if err := db.Save(&enrollment).Error; err != nil {
return nil, err
}
return &enrollment, nil
}
// IsChildBookedForDate checks if a child is booked for a given date based on their
// current enrollment's CareDaysBinary. Returns true if no enrollment exists or if
// CareDaysBinary is 0 (no restriction).
func IsChildBookedForDate(db *gorm.DB, childID uint, date time.Time) bool {
var enrollment model.Enrollment
err := db.Where("child_id = ?", childID).
Where("status = ?", EnrollmentStatusActive).
Where("(valid_from IS NULL OR valid_from <= ?)", date).
Where("(valid_until IS NULL OR valid_until >= ?)", date).
Order("valid_from DESC").
First(&enrollment).Error
if err != nil {
return true // No enrollment found — show child (default)
}
return enrollment.IsBookedForWeekday(date.Weekday())
}
// GetChildrenBookingStatus returns a map of childID → isBooked for a list of children on a given date.
// This batch-queries enrollments to avoid N+1 queries.
func GetChildrenBookingStatus(db *gorm.DB, childIDs []uint, date time.Time) map[uint]bool {
result := make(map[uint]bool, len(childIDs))
for _, id := range childIDs {
result[id] = true // Default: booked
}
if len(childIDs) == 0 {
return result
}
var enrollments []model.Enrollment
db.Where("child_id IN ?", childIDs).
Where("status = ?", EnrollmentStatusActive).
Where("(valid_from IS NULL OR valid_from <= ?)", date).
Where("(valid_until IS NULL OR valid_until >= ?)", date).
Find(&enrollments)
// Build map: childID → most recent active enrollment
enrollmentMap := make(map[uint]*model.Enrollment)
for i := range enrollments {
e := &enrollments[i]
if existing, ok := enrollmentMap[e.ChildID]; !ok || (e.ValidFrom != nil && (existing.ValidFrom == nil || e.ValidFrom.After(*existing.ValidFrom))) {
enrollmentMap[e.ChildID] = e
}
}
for childID, enrollment := range enrollmentMap {
result[childID] = enrollment.IsBookedForWeekday(date.Weekday())
}
return result
}
// SyncEnrollmentData holds the data needed to create/update an enrollment from sync
type SyncEnrollmentData struct {
ValidFrom *time.Time
ValidUntil *time.Time
Status int
CareDaysBinary int
CareDaysCount int
ExternalIDKrp *string
ExternalIDUe3 *string
Comments string
}
// ========================================
// Admin CRUD Functions
// ========================================
// GetAllEnrollments returns all enrollments with optional filtering by child, group, location, and status.
// Results are ordered by child name, then valid_from descending.
func GetAllEnrollments(db *gorm.DB, childID, groupID, locationID *uint, status *int) ([]model.Enrollment, error) {
var enrollments []model.Enrollment
query := db.Preload("Child").Preload("Group").Preload("Group.Location")
if childID != nil && *childID > 0 {
query = query.Where("child_id = ?", *childID)
}
if groupID != nil && *groupID > 0 {
query = query.Where("group_id = ?", *groupID)
}
if locationID != nil && *locationID > 0 {
query = query.Joins("JOIN groups ON groups.id = enrollments.group_id").
Where("groups.location_id = ?", *locationID)
}
if status != nil && *status > 0 {
query = query.Where("enrollments.status = ?", *status)
}
err := query.Order("enrollments.created_at DESC").Find(&enrollments).Error
if err != nil {
return nil, err
}
return enrollments, nil
}
// GetEnrollmentByID returns a single enrollment by ID with preloaded relationships.
func GetEnrollmentByID(db *gorm.DB, id uint) (*model.Enrollment, error) {
var enrollment model.Enrollment
err := db.Preload("Child").Preload("Group").Preload("Group.Location").
First(&enrollment, id).Error
if err != nil {
return nil, err
}
return &enrollment, nil
}
// CreateEnrollment creates a new enrollment and updates the child's cached GroupId.
func CreateEnrollment(db *gorm.DB, enrollment *model.Enrollment) error {
if err := db.Create(enrollment).Error; err != nil {
return err
}
// Update the child's cached GroupId
return UpdateChildGroupFromEnrollments(db, enrollment.ChildID)
}
// UpdateEnrollment updates an existing enrollment and updates the child's cached GroupId.
// If the child changed, updates both the old and new child's cached GroupId.
func UpdateEnrollment(db *gorm.DB, enrollment *model.Enrollment) error {
// Get the old enrollment to check if child changed
var oldEnrollment model.Enrollment
if err := db.First(&oldEnrollment, enrollment.ID).Error; err != nil {
return err
}
oldChildID := oldEnrollment.ChildID
if err := db.Save(enrollment).Error; err != nil {
return err
}
// Update the new child's cached GroupId
if err := UpdateChildGroupFromEnrollments(db, enrollment.ChildID); err != nil {
return err
}
// If child changed, also update the old child's cached GroupId
if oldChildID != enrollment.ChildID {
if err := UpdateChildGroupFromEnrollments(db, oldChildID); err != nil {
return err
}
}
return nil
}
// DeleteEnrollment soft-deletes an enrollment and updates the child's cached GroupId.
func DeleteEnrollment(db *gorm.DB, id uint) error {
// Get the enrollment first to know which child to update
var enrollment model.Enrollment
if err := db.First(&enrollment, id).Error; err != nil {
return err
}
childID := enrollment.ChildID
// Soft delete
if err := db.Delete(&model.Enrollment{}, id).Error; err != nil {
return err
}
// Update the child's cached GroupId
return UpdateChildGroupFromEnrollments(db, childID)
}
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// ForgejoConfig holds the configuration for Forgejo API
type ForgejoConfig struct {
URL string
Token string
Repo string // format: "owner/repo"
}
// ForgejoLabel represents a label from Forgejo API
type ForgejoLabel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
// ForgejoIssueRequest represents a request to create a new issue
type ForgejoIssueRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels,omitempty"`
Assignee string `json:"assignee,omitempty"`
}
// ForgejoService handles communication with Forgejo API
type ForgejoService struct {
config ForgejoConfig
labels []ForgejoLabel
labelsMutex sync.RWMutex
httpClient *http.Client
}
var (
forgejoServiceInstance *ForgejoService
forgejoServiceOnce sync.Once
)
// InitForgejoService initializes the global Forgejo service instance
func InitForgejoService(config ForgejoConfig) error {
var initErr error
forgejoServiceOnce.Do(func() {
forgejoServiceInstance = &ForgejoService{
config: config,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
// Fetch labels on startup
if err := forgejoServiceInstance.FetchLabels(); err != nil {
initErr = fmt.Errorf("failed to fetch labels: %w", err)
}
})
return initErr
}
// GetForgejoService returns the global Forgejo service instance
func GetForgejoService() *ForgejoService {
return forgejoServiceInstance
}
// FetchLabels fetches and caches labels from the Forgejo repository
func (s *ForgejoService) FetchLabels() error {
if s.config.URL == "" || s.config.Token == "" || s.config.Repo == "" {
return fmt.Errorf("forgejo configuration incomplete")
}
url := fmt.Sprintf("%s/api/v1/repos/%s/labels", s.config.URL, s.config.Repo)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+s.config.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch labels: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to fetch labels: status %d, body: %s", resp.StatusCode, string(body))
}
var labels []ForgejoLabel
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
return fmt.Errorf("failed to decode labels: %w", err)
}
s.labelsMutex.Lock()
s.labels = labels
s.labelsMutex.Unlock()
return nil
}
// GetLabelIDsByNames returns label IDs for the given label names
func (s *ForgejoService) GetLabelIDsByNames(names ...string) []int64 {
s.labelsMutex.RLock()
defer s.labelsMutex.RUnlock()
nameMap := make(map[string]bool)
for _, name := range names {
nameMap[name] = true
}
var ids []int64
for _, label := range s.labels {
if nameMap[label.Name] {
ids = append(ids, label.ID)
}
}
return ids
}
// FeedbackMetadata contains contextual information about the feedback
type FeedbackMetadata struct {
AppURL string
Version string
UserEmail string
Timestamp time.Time
}
// CreateIssue creates a new issue in the Forgejo repository
func (s *ForgejoService) CreateIssue(title, body string, metadata FeedbackMetadata, labelNames ...string) error {
if s.config.URL == "" || s.config.Token == "" || s.config.Repo == "" {
return fmt.Errorf("forgejo configuration incomplete")
}
// Get label IDs from names
labelIDs := s.GetLabelIDsByNames(labelNames...)
// Format body with metadata
formattedBody := fmt.Sprintf("%s\n\n---\n\n**Context:**\n- App URL: %s\n- Version: %s\n- User: %s\n- Submitted: %s",
body,
metadata.AppURL,
metadata.Version,
metadata.UserEmail,
metadata.Timestamp.Format("2006-01-02 15:04:05"),
)
issueReq := ForgejoIssueRequest{
Title: title,
Body: formattedBody,
Labels: labelIDs,
}
jsonData, err := json.Marshal(issueReq)
if err != nil {
return fmt.Errorf("failed to marshal issue request: %w", err)
}
url := fmt.Sprintf("%s/api/v1/repos/%s/issues", s.config.URL, s.config.Repo)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+s.config.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to create issue: status %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
// IsConfigured returns true if the Forgejo service is properly configured
func (s *ForgejoService) IsConfigured() bool {
if s == nil {
return false
}
return s.config.URL != "" && s.config.Token != "" && s.config.Repo != ""
}
// GetCategoryLabel returns the appropriate label name for a feedback category
func GetCategoryLabel(category string) string {
switch category {
case "bug":
return "bug"
case "request":
return "enhancement"
case "feedback":
return "feedback"
default:
return "feedback"
}
}
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// NagerDateHoliday represents a holiday entry from the Nager.Date API
type NagerDateHoliday struct {
Date string `json:"date"`
LocalName string `json:"localName"`
Name string `json:"name"`
CountryCode string `json:"countryCode"`
Global bool `json:"global"`
Counties []string `json:"counties"`
Types []string `json:"types"`
Fixed bool `json:"fixed"`
}
// HolidayImportResult tracks what happened during an import
type HolidayImportResult struct {
Created int
Skipped int
Total int
Holidays []string
}
// FetchPublicHolidays fetches holidays from the configured API for the given year
func FetchPublicHolidays(apiURL string, countryCode string, year int) ([]NagerDateHoliday, error) {
url := fmt.Sprintf("%s/api/v3/PublicHolidays/%d/%s", apiURL, year, countryCode)
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch holidays: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var holidays []NagerDateHoliday
if err := json.NewDecoder(resp.Body).Decode(&holidays); err != nil {
return nil, fmt.Errorf("failed to decode holidays: %w", err)
}
return holidays, nil
}
// FilterByBundesland returns only holidays that apply to the given Bundesland.
// Nationwide holidays (global=true) are always included.
func FilterByBundesland(holidays []NagerDateHoliday, bundesland string) []NagerDateHoliday {
var filtered []NagerDateHoliday
for _, h := range holidays {
if h.Global {
filtered = append(filtered, h)
continue
}
for _, county := range h.Counties {
if county == bundesland {
filtered = append(filtered, h)
break
}
}
}
return filtered
}
// ImportHolidaysForLocation fetches public holidays and creates calendar events for a location.
// Idempotent: skips holidays that already exist (matched by date + location + system_generated).
func ImportHolidaysForLocation(db *gorm.DB, locationID uint, bundesland string, year int, createdByID uint) (*HolidayImportResult, error) {
var settings model.HolidaySettings
dbResult := db.First(&settings)
if dbResult.Error != nil {
s, err := model.GetHolidaySettings()
if err != nil {
return nil, fmt.Errorf("failed to get holiday settings: %w", err)
}
settings = *s
}
if !settings.Enabled {
return nil, fmt.Errorf("holiday import is disabled")
}
holidays, err := FetchPublicHolidays(settings.APIURL, settings.CountryCode, year)
if err != nil {
return nil, fmt.Errorf("failed to fetch holidays: %w", err)
}
filtered := FilterByBundesland(holidays, bundesland)
result := &HolidayImportResult{
Total: len(filtered),
}
for _, h := range filtered {
date, err := time.Parse("2006-01-02", h.Date)
if err != nil {
logger.Warn("holiday import: failed to parse date",
"date", h.Date,
"name", h.LocalName,
"error", err)
continue
}
var count int64
db.Model(&model.CalendarEvent{}).
Where("start_date = ? AND location_id = ? AND system_generated = ? AND event_type = ?",
date, locationID, true, model.EventTypeHoliday).
Count(&count)
if count > 0 {
result.Skipped++
continue
}
locID := locationID
event := model.CalendarEvent{
Title: h.LocalName,
EventType: model.EventTypeHoliday,
StartDate: date,
EndDate: date,
AllDay: true,
LocationId: &locID,
CreatedById: createdByID,
SystemGenerated: true,
}
if err := db.Create(&event).Error; err != nil {
logger.Error("holiday import: failed to create event",
"name", h.LocalName,
"locationID", locationID,
"error", err)
continue
}
result.Created++
result.Holidays = append(result.Holidays, h.LocalName)
}
logger.Info("holiday import complete",
"locationID", locationID,
"year", year,
"created", result.Created,
"skipped", result.Skipped,
"total", result.Total)
return result, nil
}
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// IntranetGroupRecord represents a single group assignment from the intranet API
type IntranetGroupRecord struct {
GruppenID string `json:"Gruppen_ID"`
Stammteam bool `json:"Stammteam"` // true = core team member, false = substitute (day-limited)
}
// IntranetGroupResponse represents the response from the intranet groups API
type IntranetGroupResponse struct {
Records []IntranetGroupRecord `json:"records"`
}
// NeedsIntranetRefresh checks if the user needs an intranet group refresh today
// Returns true if:
// - User is an employee with an ExternalID (intranet-linked)
// - IntranetRefreshDate is nil or not today
func NeedsIntranetRefresh(user *model.User) bool {
// Only employees with ExternalID need intranet refresh
if user.ExternalID == nil || *user.ExternalID == "" {
return false
}
if !user.IsEmployee() {
return false
}
// Check if refresh is needed (not refreshed today)
if user.IntranetRefreshDate == nil {
return true
}
today := time.Now().Truncate(24 * time.Hour)
refreshDate := user.IntranetRefreshDate.Truncate(24 * time.Hour)
return !refreshDate.Equal(today)
}
// RefreshUserGroupMemberships queries the intranet API and updates the user's
// daily group assignments. Returns nil on success, error on failure.
// On failure, sets user.IntranetRefreshFailed = true.
func RefreshUserGroupMemberships(db *gorm.DB, user *model.User) error {
logger.Debug("========== INTRANET REFRESH START ==========")
logger.Debug("RefreshUserGroupMemberships called",
"userId", user.ID,
"email", user.Email)
// Load intranet settings
settings, err := model.GetIntranetSettings()
if err != nil {
return markRefreshFailed(db, user, fmt.Errorf("failed to load intranet settings: %w", err))
}
logger.Debug("Intranet settings loaded",
"apiURL", settings.APIURL,
"enabled", settings.Enabled,
"hasToken", settings.APIToken != "")
// Check if intranet sync is enabled
if !settings.Enabled {
logger.Debug("intranet sync disabled, skipping refresh", "userId", user.ID)
// Not an error - just skip refresh if disabled
return nil
}
// Validate settings
if settings.APIURL == "" {
return markRefreshFailed(db, user, fmt.Errorf("intranet API URL not configured"))
}
if settings.APIToken == "" {
return markRefreshFailed(db, user, fmt.Errorf("intranet API token not configured"))
}
// Ensure user has ExternalID
if user.ExternalID == nil || *user.ExternalID == "" {
return markRefreshFailed(db, user, fmt.Errorf("user has no ExternalID"))
}
externalID := *user.ExternalID
logger.Debug("User ExternalID (Mitarbeiter_ID) to be sent",
"userId", user.ID,
"email", user.Email,
"externalID", externalID)
// Call the intranet API
records, err := callIntranetGroupsAPI(settings.APIURL, settings.APIToken, externalID)
if err != nil {
return markRefreshFailed(db, user, fmt.Errorf("intranet API call failed: %w", err))
}
// Update the database in a transaction
today := time.Now().Truncate(24 * time.Hour)
err = db.Transaction(func(tx *gorm.DB) error {
// Delete existing entries for this user and today
if err := tx.Where("user_id = ? AND assignment_date = ?", user.ID, today).
Delete(&model.EmployeeDailyGroup{}).Error; err != nil {
return fmt.Errorf("failed to clear existing daily groups: %w", err)
}
// Find which records have groups that exist in our system
// Priority: Stammteam=false (substitute) over Stammteam=true (core team)
// This determines where the employee is actually working today
type validRecord struct {
record IntranetGroupRecord
group model.Group
}
var substituteRecords []validRecord
var coreTeamRecords []validRecord
for _, record := range records {
var group model.Group
if err := tx.Where("external_id = ?", record.GruppenID).First(&group).Error; err != nil {
if err == gorm.ErrRecordNotFound {
logger.Warn("group not found for intranet assignment",
"userId", user.ID,
"groupExternalId", record.GruppenID,
"stammteam", record.Stammteam)
continue // Skip unknown groups
}
return fmt.Errorf("failed to find group %s: %w", record.GruppenID, err)
}
vr := validRecord{record: record, group: group}
if record.Stammteam {
coreTeamRecords = append(coreTeamRecords, vr)
} else {
substituteRecords = append(substituteRecords, vr)
}
}
// Use substitute records if any exist, otherwise fall back to core team
recordsToUse := substituteRecords
if len(substituteRecords) == 0 {
recordsToUse = coreTeamRecords
logger.Debug("No substitute groups found in system, using core team",
"coreTeamCount", len(coreTeamRecords))
} else if len(coreTeamRecords) > 0 {
logger.Debug("Using substitute assignments, ignoring core team",
"substituteCount", len(substituteRecords),
"ignoredCoreTeamCount", len(coreTeamRecords))
}
// Create daily group assignments for selected records
for _, vr := range recordsToUse {
dailyGroup := model.EmployeeDailyGroup{
UserID: user.ID,
GroupID: vr.group.ID,
AssignmentDate: today,
IsCoreTeam: vr.record.Stammteam,
}
if err := tx.Create(&dailyGroup).Error; err != nil {
return fmt.Errorf("failed to create daily group assignment: %w", err)
}
}
// Update user's refresh status
if err := tx.Model(user).Updates(map[string]interface{}{
"intranet_refresh_date": today,
"intranet_refresh_failed": false,
}).Error; err != nil {
return fmt.Errorf("failed to update user refresh status: %w", err)
}
return nil
})
if err != nil {
return markRefreshFailed(db, user, err)
}
logger.Info("successfully refreshed intranet group memberships",
"userId", user.ID,
"email", user.Email,
"groupCount", len(records))
logger.Debug("========== INTRANET REFRESH COMPLETE ==========")
return nil
}
// callIntranetGroupsAPI makes the HTTP request to the intranet API
func callIntranetGroupsAPI(apiURL, apiToken, mitarbeiterID string) ([]IntranetGroupRecord, error) {
// Build form data
formData := url.Values{}
formData.Set("Token", apiToken)
formData.Set("Mitarbeiter_ID", mitarbeiterID)
// Debug: Log the request details
logger.Debug("---------- INTRANET API REQUEST ----------")
logger.Debug("Request URL", "url", apiURL)
logger.Debug("Request Method", "method", "POST")
logger.Debug("Request Content-Type", "contentType", "application/x-www-form-urlencoded")
logger.Debug("Request Authorization", "header", "Bearer "+maskToken(apiToken))
logger.Debug("Request Form Data",
"Mitarbeiter_ID", mitarbeiterID,
"Token", maskToken(apiToken))
logger.Debug("Request Body (raw)", "body", fmt.Sprintf("Token=%s&Mitarbeiter_ID=%s", maskToken(apiToken), mitarbeiterID))
// Create request
req, err := http.NewRequest("POST", apiURL, strings.NewReader(formData.Encode()))
if err != nil {
logger.Debug("Failed to create request", "error", err)
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+apiToken)
// Execute request with timeout
logger.Debug("Sending HTTP request...")
client := &http.Client{Timeout: 30 * time.Second}
startTime := time.Now()
resp, err := client.Do(req)
elapsed := time.Since(startTime)
if err != nil {
logger.Debug("HTTP request failed",
"error", err,
"elapsed", elapsed.String())
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
logger.Debug("---------- INTRANET API RESPONSE ----------")
logger.Debug("Response received",
"statusCode", resp.StatusCode,
"status", resp.Status,
"elapsed", elapsed.String())
// Log response headers
for key, values := range resp.Header {
logger.Debug("Response Header", "key", key, "value", strings.Join(values, ", "))
}
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.Debug("Failed to read response body", "error", err)
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Log raw response body
logger.Debug("Response Body (raw)",
"length", len(body),
"body", string(body))
// Check status code
if resp.StatusCode != http.StatusOK {
logger.Debug("API returned non-OK status",
"statusCode", resp.StatusCode,
"body", string(body))
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
// Parse and pretty-print JSON response
var response IntranetGroupResponse
if err := json.Unmarshal(body, &response); err != nil {
logger.Debug("Failed to parse JSON response",
"error", err,
"body", string(body))
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body))
}
// Pretty print the parsed response
prettyJSON, _ := json.MarshalIndent(response, "", " ")
logger.Debug("Response Body (parsed & pretty)",
"json", string(prettyJSON))
// Log each record for easy reading
logger.Debug("---------- PARSED RECORDS ----------")
logger.Debug("Total records received", "count", len(response.Records))
for i, record := range response.Records {
logger.Debug(fmt.Sprintf("Record[%d]", i),
"GruppenID", record.GruppenID,
"Stammteam", record.Stammteam,
"isCoreTeam", record.Stammteam)
}
logger.Debug("========== INTRANET REFRESH API CALL COMPLETE ==========")
return response.Records, nil
}
// maskToken masks the API token for secure logging (shows first 4 and last 4 chars)
func maskToken(token string) string {
if len(token) <= 8 {
return "****"
}
return token[:4] + "..." + token[len(token)-4:]
}
// markRefreshFailed sets the user's IntranetRefreshFailed flag to true
func markRefreshFailed(db *gorm.DB, user *model.User, originalError error) error {
if updateErr := db.Model(user).Update("intranet_refresh_failed", true).Error; updateErr != nil {
logger.Error("failed to mark intranet refresh as failed",
"userId", user.ID,
"updateError", updateErr,
"originalError", originalError)
}
logger.Warn("intranet refresh failed",
"userId", user.ID,
"email", user.Email,
"error", originalError)
return originalError
}
// TestIntranetConnection tests the connection to the intranet API with a test employee ID
func TestIntranetConnection(settings *model.IntranetSettings, testEmployeeID string) error {
if settings.APIURL == "" {
return fmt.Errorf("API URL not configured")
}
if settings.APIToken == "" {
return fmt.Errorf("API token not configured")
}
// Try to call the API
_, err := callIntranetGroupsAPI(settings.APIURL, settings.APIToken, testEmployeeID)
if err != nil {
return fmt.Errorf("connection test failed: %w", err)
}
return nil
}
// GetUserDailyGroups returns the employee's daily group assignments for today
// This is used for authorization checks
func GetUserDailyGroups(db *gorm.DB, userID uint) ([]model.EmployeeDailyGroup, error) {
today := time.Now().Truncate(24 * time.Hour)
var dailyGroups []model.EmployeeDailyGroup
err := db.Preload("Group").Preload("Group.Location").
Where("user_id = ? AND assignment_date = ?", userID, today).
Find(&dailyGroups).Error
if err != nil {
return nil, err
}
return dailyGroups, nil
}
// GetUserDailyLocationIDs returns the location IDs from the user's daily group assignments
func GetUserDailyLocationIDs(db *gorm.DB, userID uint) ([]uint, error) {
dailyGroups, err := GetUserDailyGroups(db, userID)
if err != nil {
return nil, err
}
locationIDsMap := make(map[uint]bool)
for _, dg := range dailyGroups {
if dg.Group.LocationId != 0 {
locationIDsMap[dg.Group.LocationId] = true
}
}
locationIDs := make([]uint, 0, len(locationIDsMap))
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
return locationIDs, nil
}
// ClearUserDailyGroups clears all daily group assignments for a user
// Used for testing or manual reset
func ClearUserDailyGroups(db *gorm.DB, userID uint) error {
return db.Where("user_id = ?", userID).Delete(&model.EmployeeDailyGroup{}).Error
}
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
const (
// InvitationCodeLength is the number of random bytes (will be 32 hex chars)
InvitationCodeLength = 16
// InvitationExpiryDays is the default expiration period for invitation codes
InvitationExpiryDays = 30
)
// InvitationService handles invitation code operations
type InvitationService struct {
db *gorm.DB
}
// NewInvitationService creates a new invitation service
func NewInvitationService(db *gorm.DB) *InvitationService {
return &InvitationService{db: db}
}
// GenerateInvitationCode creates a new invitation code for a child
func (s *InvitationService) GenerateInvitationCode(childID uint, role model.RelationshipRole, createdByID uint) (*model.InvitationCode, error) {
// Generate secure random code
code, err := generateSecureCode()
if err != nil {
return nil, err
}
invitation := &model.InvitationCode{
Code: code,
ChildID: childID,
RelationshipRole: role,
ExpiresAt: time.Now().AddDate(0, 0, InvitationExpiryDays),
CreatedByID: createdByID,
}
if err := s.db.Create(invitation).Error; err != nil {
return nil, err
}
// Preload child data for display
s.db.Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").Preload("CreatedBy").First(invitation, invitation.ID)
return invitation, nil
}
// GetInvitationByCode retrieves an invitation by its code
func (s *InvitationService) GetInvitationByCode(code string) (*model.InvitationCode, error) {
var invitation model.InvitationCode
err := s.db.Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Preload("CreatedBy").
Where("code = ?", code).First(&invitation).Error
if err != nil {
return nil, err
}
return &invitation, nil
}
// GetInvitationsForChild returns all invitation codes for a child
func (s *InvitationService) GetInvitationsForChild(childID uint) ([]model.InvitationCode, error) {
var invitations []model.InvitationCode
err := s.db.Preload("CreatedBy").
Where("child_id = ?", childID).
Order("created_at DESC").
Find(&invitations).Error
return invitations, err
}
// UseInvitationCode marks the code as used
func (s *InvitationService) UseInvitationCode(code string, email string) error {
var invitation model.InvitationCode
if err := s.db.Where("code = ?", code).First(&invitation).Error; err != nil {
return errors.New("invitation code not found")
}
if !invitation.IsValid() {
if invitation.IsUsed() {
return errors.New("invitation code has already been used")
}
if invitation.IsRevoked() {
return errors.New("invitation code has been revoked")
}
if invitation.IsExpired() {
return errors.New("invitation code has expired")
}
return errors.New("invitation code is not valid")
}
now := time.Now()
normalizedEmail := strings.ToLower(strings.TrimSpace(email))
invitation.UsedAt = &now
invitation.UsedByEmail = &normalizedEmail
return s.db.Save(&invitation).Error
}
// RevokeInvitationCode revokes an invitation code.
//
// Audit #464: pre-fix, this checked nothing about the relationship
// between the revoking user and the invitation's child — any
// GroupLead/LocationLead/Admin who could reach the route could revoke
// any active invitation by ID-guess, regardless of location. The
// CanUserAccessChild gate (admin-bypass, location-scoped for staff)
// brings this in line with the rest of the child-scoped surface.
//
// errAccessDenied is returned (not a generic "not found") so callers
// can distinguish unauthorized from missing if they want, but the
// thin HTTP layer can just treat the error opaquely.
func (s *InvitationService) RevokeInvitationCode(codeID uint, revokedByID uint) error {
var invitation model.InvitationCode
if err := s.db.First(&invitation, codeID).Error; err != nil {
return err
}
canAccess, err := model.CanUserAccessChild(s.db, revokedByID, invitation.ChildID)
if err != nil {
return err
}
if !canAccess {
return errAccessDenied
}
if invitation.UsedAt != nil {
return errors.New("cannot revoke a used invitation code")
}
if invitation.RevokedAt != nil {
return errors.New("invitation code is already revoked")
}
now := time.Now()
invitation.RevokedAt = &now
invitation.RevokedByID = &revokedByID
return s.db.Save(&invitation).Error
}
// errAccessDenied is the sentinel error returned by service methods
// when the caller is not authorized for the target resource.
var errAccessDenied = errors.New("access denied")
// CanUserGenerateInvitation checks if a user has permission to generate invitations for a child
func (s *InvitationService) CanUserGenerateInvitation(userID, childID uint) (bool, error) {
return model.CanUserAccessChild(s.db, userID, childID)
}
// GenerateBulkInvitations creates invitation codes for multiple children
// Each child gets one code per specified relationship role
func (s *InvitationService) GenerateBulkInvitations(childIDs []uint, roles []model.RelationshipRole, createdByID uint) ([]*model.InvitationCode, error) {
var invitations []*model.InvitationCode
err := s.db.Transaction(func(tx *gorm.DB) error {
for _, childID := range childIDs {
for _, role := range roles {
// Generate secure random code
code, err := generateSecureCode()
if err != nil {
return err
}
invitation := &model.InvitationCode{
Code: code,
ChildID: childID,
RelationshipRole: role,
ExpiresAt: time.Now().AddDate(0, 0, InvitationExpiryDays),
CreatedByID: createdByID,
}
if err := tx.Create(invitation).Error; err != nil {
return err
}
invitations = append(invitations, invitation)
}
}
return nil
})
if err != nil {
return nil, err
}
// Preload all related data for display
var invitationIDs []uint
for _, inv := range invitations {
invitationIDs = append(invitationIDs, inv.ID)
}
var preloadedInvitations []*model.InvitationCode
s.db.Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Where("id IN ?", invitationIDs).
Order("child_id ASC, relationship_role ASC").
Find(&preloadedInvitations)
return preloadedInvitations, nil
}
// GetInvitationsByIDs retrieves invitations by their IDs
func (s *InvitationService) GetInvitationsByIDs(ids []uint) ([]*model.InvitationCode, error) {
var invitations []*model.InvitationCode
err := s.db.Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Where("id IN ?", ids).
Order("child_id ASC, relationship_role ASC").
Find(&invitations).Error
return invitations, err
}
// generateSecureCode generates a cryptographically secure random code
func generateSecureCode() (string, error) {
bytes := make([]byte, InvitationCodeLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
package service
import (
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// AuthorableScopes describes which groups a user can author news / parental
// letters for, taking into account both their direct lead role and any active
// stand-ins (Stellvertretung) they currently hold from other leads.
type AuthorableScopes struct {
// Groups the user may target on a create form, deduplicated.
Groups []model.Group
// Distinct lead Users on whose behalf the actor currently holds an active
// stand-in. Empty when the user has no stand-ins.
StandInLeads []model.User
// LocationWideAllowed reports whether the user may post a location-wide
// item (no specific group). True if the user is themselves a LocationLead
// or Admin, or if they hold an active stand-in from a LocationLead.
LocationWideAllowed bool
}
// AuthorableScopesForUser computes the union of groups the user may author
// news / letters for. Designed to back the dropdown on the create pages for
// both news and parental letters.
func AuthorableScopesForUser(db *gorm.DB, user *model.User) (AuthorableScopes, error) {
var out AuthorableScopes
// 1. Direct authority: user is themselves a lead.
if user.IsHouseLeader() || user.IsAdmin() {
out.LocationWideAllowed = true
}
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(db)
if err == nil && len(locationIDs) > 0 {
var groups []model.Group
db.Where("location_id IN ?", locationIDs).
Preload("Location").
Find(&groups)
out.Groups = appendGroupsUnique(out.Groups, groups)
}
} else if user.IsGroupLeader() {
groups, err := user.GetEmployeeCoreTeamGroups(db)
if err == nil {
out.Groups = appendGroupsUnique(out.Groups, groups)
}
}
// 2. Stand-in-derived authority: for each active stand-in, fold in the
// delegating lead's scope.
standIns, err := model.ActiveStandInsForDelegate(db, user.ID)
if err != nil {
return out, err
}
seenLeads := make(map[uint]bool)
for i := range standIns {
s := &standIns[i]
if seenLeads[s.DelegatorID] {
continue
}
seenLeads[s.DelegatorID] = true
out.StandInLeads = append(out.StandInLeads, s.Delegator)
// LocationLead delegator → all groups in their location(s).
if s.Delegator.IsHouseLeader() {
out.LocationWideAllowed = true
locIDs, err := s.Delegator.GetLocationIDs(db)
if err == nil && len(locIDs) > 0 {
var groups []model.Group
db.Where("location_id IN ?", locIDs).
Preload("Location").
Find(&groups)
out.Groups = appendGroupsUnique(out.Groups, groups)
}
continue
}
// GroupLead delegator → just the group(s) they lead.
if s.Delegator.IsGroupLeader() {
var groups []model.Group
db.Where("lead_id = ?", s.Delegator.ID).
Preload("Location").
Find(&groups)
out.Groups = appendGroupsUnique(out.Groups, groups)
}
}
return out, nil
}
func appendGroupsUnique(existing, addition []model.Group) []model.Group {
seen := make(map[uint]bool, len(existing))
for _, g := range existing {
seen[g.ID] = true
}
for _, g := range addition {
if !seen[g.ID] {
existing = append(existing, g)
seen[g.ID] = true
}
}
return existing
}
package service
import (
"log"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// MigrateChildGroupsToEnrollments creates Enrollment records from existing Child.GroupId data.
// This is a one-time migration to be run during deployment.
// It preserves existing data by creating enrollment records without modifying the Child records.
func MigrateChildGroupsToEnrollments(db *gorm.DB) error {
log.Println("[MIGRATION] MigrateChildGroupsToEnrollments: START")
// Find all children that have a group assigned but no enrollments yet
var children []model.Child
err := db.Where("group_id IS NOT NULL").Find(&children).Error
if err != nil {
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: FAILED to load children: %v", err)
return err
}
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: Found %d children with group assignments", len(children))
created := 0
skipped := 0
errors := 0
now := time.Now()
for _, child := range children {
if child.GroupId == nil {
continue
}
// Check if an enrollment already exists for this child and group
var existingCount int64
db.Model(&model.Enrollment{}).
Where("child_id = ? AND group_id = ?", child.ID, *child.GroupId).
Count(&existingCount)
if existingCount > 0 {
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: SKIPPED ChildID=%d - enrollment already exists", child.ID)
skipped++
continue
}
// Create enrollment record from child's current data
enrollment := model.Enrollment{
ChildID: child.ID,
GroupID: *child.GroupId,
ValidFrom: child.ValidFrom,
ValidUntil: child.ValidUntil,
Status: EnrollmentStatusActive, // Assume active since it's the current assignment
SyncedAt: now,
Comments: "Migrated from Child.GroupId",
}
if err := db.Create(&enrollment).Error; err != nil {
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: FAILED to create enrollment for ChildID=%d: %v", child.ID, err)
errors++
continue
}
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: Created EnrollmentID=%d for ChildID=%d GroupID=%d", enrollment.ID, child.ID, *child.GroupId)
created++
}
log.Printf("[MIGRATION] MigrateChildGroupsToEnrollments: COMPLETE - Created=%d Skipped=%d Errors=%d", created, skipped, errors)
return nil
}
// RunMigrationIfNeeded checks if the enrollment migration needs to run and executes it.
// It's safe to call multiple times - it will skip children that already have enrollments.
func RunMigrationIfNeeded(db *gorm.DB) error {
// Check if there are any enrollments at all
var enrollmentCount int64
db.Model(&model.Enrollment{}).Count(&enrollmentCount)
// Check if there are children with groups but no enrollments
var childrenWithGroupsCount int64
db.Model(&model.Child{}).Where("group_id IS NOT NULL").Count(&childrenWithGroupsCount)
if enrollmentCount == 0 && childrenWithGroupsCount > 0 {
log.Printf("[MIGRATION] RunMigrationIfNeeded: Found %d children with groups but no enrollments - running migration", childrenWithGroupsCount)
return MigrateChildGroupsToEnrollments(db)
}
log.Printf("[MIGRATION] RunMigrationIfNeeded: No migration needed (enrollments=%d, childrenWithGroups=%d)", enrollmentCount, childrenWithGroupsCount)
return nil
}
package service
import (
"fmt"
"sort"
"strings"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// CanUserViewNews checks if a user can view a specific news entry (used for attachment access control)
func CanUserViewNews(db *gorm.DB, user *model.User, news *model.News) bool {
// Admin can view any news
if user.IsAdmin() {
return true
}
// Global news (LocationId == nil) — visible to everyone
if news.LocationId == nil {
return true
}
if user.IsEmployee() {
// Employee: check if user's location IDs overlap with the news location
locationIDs, err := user.GetLocationIDs(db)
if err != nil {
return false
}
for _, locID := range locationIDs {
if *news.LocationId == locID {
return true
}
}
return false
}
if user.IsParent() {
// Parent: check if children's group/location overlap with the news
validChildren, err := model.GetValidChildrenForUser(db, user.ID)
if err != nil {
return false
}
for _, child := range validChildren {
if child.Group != nil && child.GroupId != nil {
// If news is for a specific group, check group match
if news.GroupId != nil && *child.GroupId == *news.GroupId {
return true
}
// If news is location-wide, check location match
if news.GroupId == nil && child.Group.LocationId == *news.LocationId {
return true
}
}
}
return false
}
return false
}
// CanUserCreateNews checks if a user can create a news entry for the specified scope.
// Returns true if the user is directly authorised, or if they currently hold an
// active stand-in (Stellvertretung) from a lead with authority over that scope.
func CanUserCreateNews(db *gorm.DB, user *model.User, groupId, locationId *uint) bool {
ok, _ := AuthorizeCreateNews(db, user, groupId, locationId)
return ok
}
// AuthorizeCreateNews returns whether the user can create news for the given
// scope, and, if authorisation comes from a stand-in (not a direct lead role),
// the lead User on whose behalf the actor is acting. standInFor is nil for
// direct authorisation and for Admin.
func AuthorizeCreateNews(db *gorm.DB, user *model.User, groupId, locationId *uint) (ok bool, standInFor *model.User) {
if canUserCreateNewsAsLead(db, user, groupId, locationId) {
return true, nil
}
standIn, err := model.HasActiveStandInFrom(db, user.ID, groupId, locationId)
if err == nil && standIn != nil {
return true, standIn
}
return false, nil
}
// canUserCreateNewsAsLead is the original direct-authorisation check (no stand-in fallback).
// This considers effective roles - substitutes are downgraded to Employee at non-home locations.
func canUserCreateNewsAsLead(db *gorm.DB, user *model.User, groupId, locationId *uint) bool {
// Admin can create any news
if user.IsAdmin() {
return true
}
// Determine the target location for effective role check
var targetLocationID uint
if locationId != nil && *locationId > 0 {
targetLocationID = *locationId
} else if groupId != nil && *groupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *groupId).Error; err != nil {
return false
}
targetLocationID = group.LocationId
} else {
return false // No target specified
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot create news
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can create for their location or any group in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can create for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If creating for a specific group, verify user is assigned to it
if groupId != nil && *groupId > 0 {
for _, g := range userGroups {
if g.ID == *groupId {
return true
}
}
return false
}
// GroupLead cannot create location-wide news (only LocationLead can)
return false
}
return false
}
// CanUserEditNews checks if a user can edit a specific news entry
// This considers effective roles - substitutes are downgraded to Employee at non-home locations
func CanUserEditNews(db *gorm.DB, user *model.User, news *model.News) bool {
// Admin can edit any news (including global news)
if user.IsAdmin() {
return true
}
// Global news (LocationId == nil) can only be edited by admins
if news.LocationId == nil {
return false
}
// Author can always edit their own news
if news.CreatedById == user.ID {
return true
}
// Determine the target location for effective role check
targetLocationID := *news.LocationId
if news.GroupId != nil && *news.GroupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *news.GroupId).Error; err == nil {
targetLocationID = group.LocationId
}
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot edit news
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can edit location-wide or group news in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can edit news for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If news is for a specific group, verify user is assigned to it
if news.GroupId != nil && *news.GroupId > 0 {
for _, g := range userGroups {
if g.ID == *news.GroupId {
return true
}
}
return false
}
// If news is location-wide, check if location matches user's groups
for _, g := range userGroups {
if g.LocationId == *news.LocationId {
return true
}
}
return false
}
return false
}
// CanUserDeleteNews checks if a user can delete a specific news entry
func CanUserDeleteNews(db *gorm.DB, user *model.User, news *model.News) bool {
// Use same permissions as editing
return CanUserEditNews(db, user, news)
}
// GetNewsRecipients returns all parent users who should see the news
func GetNewsRecipients(db *gorm.DB, news *model.News) ([]model.User, error) {
var userIDs []uint
if news.GroupId != nil && *news.GroupId > 0 {
// Get all parent user IDs with children in this group
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_children ON user_children.user_id = users.id").
Joins("JOIN children ON children.id = user_children.child_id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Parent").
Where("children.group_id = ?", *news.GroupId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get group recipient IDs: %w", err)
}
} else if news.LocationId != nil && *news.LocationId > 0 {
// Get all parent user IDs with children in this location
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_children ON user_children.user_id = users.id").
Joins("JOIN children ON children.id = user_children.child_id").
Joins("JOIN groups ON groups.id = children.group_id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Parent").
Where("groups.location_id = ?", *news.LocationId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get location recipient IDs: %w", err)
}
} else {
// Global news - get ALL parent user IDs
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Parent").
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get global recipient IDs: %w", err)
}
}
if len(userIDs) == 0 {
return []model.User{}, nil
}
// Fetch users with Children preloaded for DisplayNameWithChildren()
var recipients []model.User
err := db.Preload("Children").Where("id IN ?", userIDs).Find(&recipients).Error
if err != nil {
return nil, fmt.Errorf("failed to load recipients with children: %w", err)
}
return recipients, nil
}
// MarkNewsAsRead marks a news entry as read by a specific user
func MarkNewsAsRead(db *gorm.DB, newsId, userId uint) error {
var newsRead model.NewsRead
// Check if already read
err := db.Where("news_id = ? AND user_id = ?", newsId, userId).
First(&newsRead).Error
if err == gorm.ErrRecordNotFound {
// Create new read record
now := time.Now()
newsRead = model.NewsRead{
NewsId: newsId,
UserId: userId,
ReadAt: &now,
}
return db.Create(&newsRead).Error
} else if err != nil {
return fmt.Errorf("failed to check news read status: %w", err)
}
// Update read time if not set
if newsRead.ReadAt == nil {
now := time.Now()
newsRead.ReadAt = &now
return db.Save(&newsRead).Error
}
return nil
}
// GetNewsReadStatistics returns lists of users who have read and not read a news entry
func GetNewsReadStatistics(db *gorm.DB, newsId uint) (read []model.User, unread []model.User, err error) {
var news model.News
err = db.Preload("Group").Preload("Location").First(&news, newsId).Error
if err != nil {
return nil, nil, fmt.Errorf("failed to load news: %w", err)
}
// Get all recipients
recipients, err := GetNewsRecipients(db, &news)
if err != nil {
return nil, nil, err
}
// Get all read records for this news
var readRecords []model.NewsRead
err = db.Where("news_id = ? AND read_at IS NOT NULL", newsId).Find(&readRecords).Error
if err != nil {
return nil, nil, fmt.Errorf("failed to get read records: %w", err)
}
// Create map of users who have read
readUserIds := make(map[uint]bool)
for _, record := range readRecords {
readUserIds[record.UserId] = true
}
// Separate into read and unread lists
for _, recipient := range recipients {
if readUserIds[recipient.ID] {
read = append(read, recipient)
} else {
unread = append(unread, recipient)
}
}
// Sort both lists alphabetically by last name, then first name
sortUsersByName(read)
sortUsersByName(unread)
return read, unread, nil
}
// sortUsersByName sorts a slice of users alphabetically by last name, then first name (case-insensitive).
func sortUsersByName(users []model.User) {
sort.Slice(users, func(i, j int) bool {
li := strings.ToLower(users[i].LastName)
lj := strings.ToLower(users[j].LastName)
if li != lj {
return li < lj
}
return strings.ToLower(users[i].FirstName) < strings.ToLower(users[j].FirstName)
})
}
// NewsReadStats holds read statistics for news (used for global news percentage display)
type NewsReadStats struct {
TotalCount int
ReadCount int
UnreadCount int
ReadPercent int
ReadUsers []model.User // Only populated for non-global news
UnreadUsers []model.User // Only populated for non-global news
IsGlobal bool
}
// GetNewsReadStatsExtended returns comprehensive read statistics
// For global news: returns counts and percentages (no user lists)
// For location/group news: returns full user lists
func GetNewsReadStatsExtended(db *gorm.DB, newsId uint) (*NewsReadStats, error) {
var news model.News
err := db.Preload("Group").Preload("Location").First(&news, newsId).Error
if err != nil {
return nil, fmt.Errorf("failed to load news: %w", err)
}
isGlobal := news.LocationId == nil
// Get all recipients
recipients, err := GetNewsRecipients(db, &news)
if err != nil {
return nil, err
}
// Get all read records for this news
var readRecords []model.NewsRead
err = db.Where("news_id = ? AND read_at IS NOT NULL", newsId).Find(&readRecords).Error
if err != nil {
return nil, fmt.Errorf("failed to get read records: %w", err)
}
// Create map of users who have read
readUserIds := make(map[uint]bool)
for _, record := range readRecords {
readUserIds[record.UserId] = true
}
stats := &NewsReadStats{
TotalCount: len(recipients),
IsGlobal: isGlobal,
}
// Count and optionally build user lists
for _, recipient := range recipients {
if readUserIds[recipient.ID] {
stats.ReadCount++
if !isGlobal {
stats.ReadUsers = append(stats.ReadUsers, recipient)
}
} else {
stats.UnreadCount++
if !isGlobal {
stats.UnreadUsers = append(stats.UnreadUsers, recipient)
}
}
}
// Calculate percentage
if stats.TotalCount > 0 {
stats.ReadPercent = (stats.ReadCount * 100) / stats.TotalCount
}
return stats, nil
}
package service
import (
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// BadgeConfig represents a single notification badge's display state
type BadgeConfig struct {
Type string // "news", "letters", "messages", "pending_actions", "absence_notices", "chat_messages"
Count int
Visible bool
URL string // Navigation target
}
// NotificationBadges contains all badge states computed for the current user
type NotificationBadges struct {
News BadgeConfig
Letters BadgeConfig
Messages BadgeConfig
PendingActions BadgeConfig
AbsenceNotices BadgeConfig
ChatMessages BadgeConfig
MessageResponses BadgeConfig
LetterResponses BadgeConfig
GroupMessages BadgeConfig // Kleinteam - messages from group colleagues
LocationMessages BadgeConfig // Grossteam - messages from location colleagues
ShowHamburgerDot bool // Computed from ALL visible badges with count > 0
TotalUnread int // Sum of all visible badge counts
}
// NotificationBadgeService is the single source of truth for notification badge computation
type NotificationBadgeService struct{}
// NewNotificationBadgeService creates a new NotificationBadgeService
func NewNotificationBadgeService() *NotificationBadgeService {
return &NotificationBadgeService{}
}
// ComputeBadges calculates all notification badges for a user based on their active role
// This is the single source of truth for badge visibility and counts
func (s *NotificationBadgeService) ComputeBadges(db *gorm.DB, user *model.User, activeRole string, lang string, adminLocationID int) NotificationBadges {
badges := NotificationBadges{}
if user == nil || user.ID == 0 {
return badges
}
isParent := activeRole == "Parent"
isEmployee := activeRole == "Employee" || user.IsEmployee()
isAdmin := user.IsAdmin()
// News badge - visible to all roles
badges.News = s.computeNewsBadge(db, user, lang)
// Letters badge - visible to all roles (shows different counts based on role)
badges.Letters = s.computeLettersBadge(db, user, isParent, lang)
// Messages badge - visible to Parents only
if isParent {
badges.Messages = s.computeMessagesBadge(db, user, lang)
}
// Employee-specific badges (only when in Employee role or is Employee without being Parent)
if !isParent && isEmployee {
// Pending Actions badge - for employees only
badges.PendingActions = s.computePendingActionsBadge(db, user, lang)
// Absence Notices badge - for employees only
badges.AbsenceNotices = s.computeAbsenceNoticesBadge(db, user, lang)
// Chat Messages badge - for employees only
badges.ChatMessages = s.computeChatMessagesBadge(db, user, lang)
// Message Responses badge - for employees who created messages with answers
badges.MessageResponses = s.computeMessageResponsesBadge(db, user, lang)
// Letter Responses badge - for employees who created letters with answers
badges.LetterResponses = s.computeLetterResponsesBadge(db, user, lang)
// Group Messages badge - messages from colleagues in same group (Kleinteam)
badges.GroupMessages = s.computeGroupMessagesBadge(db, user, lang)
// Location Messages badge - messages from colleagues at same location (Grossteam)
badges.LocationMessages = s.computeLocationMessagesBadge(db, user, lang)
}
// Admin with location selected can also see chat messages and response badges
if isAdmin && adminLocationID > 0 {
badges.ChatMessages = s.computeChatMessagesBadge(db, user, lang)
badges.MessageResponses = s.computeMessageResponsesBadge(db, user, lang)
badges.LetterResponses = s.computeLetterResponsesBadge(db, user, lang)
}
// Compute ShowHamburgerDot - aggregate of ALL visible badges with count > 0
badges.ShowHamburgerDot = s.computeShowHamburgerDot(badges)
// Compute total unread count
badges.TotalUnread = s.computeTotalUnread(badges)
return badges
}
// computeNewsBadge calculates the news badge configuration
func (s *NotificationBadgeService) computeNewsBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnreadNewsCount(db, user.ID)
return BadgeConfig{
Type: "news",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/news",
}
}
// computeLettersBadge calculates the parental letters badge configuration
func (s *NotificationBadgeService) computeLettersBadge(db *gorm.DB, user *model.User, isParent bool, lang string) BadgeConfig {
var count int
if isParent {
count = GetUnreadLettersCount(db, user.ID)
} else {
count = GetUnreadLettersCountForEmployee(db, user.ID)
}
return BadgeConfig{
Type: "letters",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/parentalletters",
}
}
// computeMessagesBadge calculates the messages badge configuration (parents only)
func (s *NotificationBadgeService) computeMessagesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnreadMessagesCount(db, user.ID)
return BadgeConfig{
Type: "messages",
Count: count,
Visible: count > 0, // Badge visible if there are unread messages
URL: "/" + lang + "/messages",
}
}
// computePendingActionsBadge calculates the pending actions badge configuration (employees only)
func (s *NotificationBadgeService) computePendingActionsBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetPendingEmployeeActionsCount(db, user.ID)
return BadgeConfig{
Type: "pending_actions",
Count: count,
Visible: count > 0, // Badge visible if there are pending actions
URL: "/" + lang + "/parentalletters",
}
}
// computeAbsenceNoticesBadge calculates the absence notices badge configuration (employees only)
func (s *NotificationBadgeService) computeAbsenceNoticesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnacknowledgedAbsenceNoticesCount(db, user.ID)
return BadgeConfig{
Type: "absence_notices",
Count: count,
Visible: count > 0, // Badge visible if there are unacknowledged absences
URL: "/" + lang + "/notifications",
}
}
// computeChatMessagesBadge calculates the chat messages badge configuration (employees only)
func (s *NotificationBadgeService) computeChatMessagesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnreadChatMessagesCount(db, user.ID)
return BadgeConfig{
Type: "chat_messages",
Count: count,
Visible: count > 0, // Badge visible if there are unread chat messages
URL: "/" + lang + "/chat",
}
}
// computeMessageResponsesBadge calculates the message responses badge configuration (employees only)
func (s *NotificationBadgeService) computeMessageResponsesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnseenMessageResponsesCount(db, user.ID)
return BadgeConfig{
Type: "message_responses",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/messages",
}
}
// computeLetterResponsesBadge calculates the letter responses badge configuration (employees only)
func (s *NotificationBadgeService) computeLetterResponsesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnseenLetterResponsesCount(db, user.ID)
return BadgeConfig{
Type: "letter_responses",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/parentalletters",
}
}
// computeGroupMessagesBadge calculates the group colleague messages badge (Kleinteam)
func (s *NotificationBadgeService) computeGroupMessagesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnviewedGroupMessagesCount(db, user)
return BadgeConfig{
Type: "group_messages",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/messages?tab=group",
}
}
// computeLocationMessagesBadge calculates the location colleague messages badge (Grossteam)
func (s *NotificationBadgeService) computeLocationMessagesBadge(db *gorm.DB, user *model.User, lang string) BadgeConfig {
count := GetUnviewedLocationMessagesCount(db, user)
return BadgeConfig{
Type: "location_messages",
Count: count,
Visible: count > 0,
URL: "/" + lang + "/messages?tab=location",
}
}
// computeShowHamburgerDot determines if the hamburger menu should show a notification dot
// This is the KEY FIX: it now includes ALL visible badges with count > 0
func (s *NotificationBadgeService) computeShowHamburgerDot(badges NotificationBadges) bool {
// Parent badges
if badges.News.Visible && badges.News.Count > 0 {
return true
}
if badges.Letters.Visible && badges.Letters.Count > 0 {
return true
}
if badges.Messages.Visible && badges.Messages.Count > 0 {
return true
}
// Employee badges - THE BUG FIX: now includes chat messages!
if badges.PendingActions.Visible && badges.PendingActions.Count > 0 {
return true
}
if badges.AbsenceNotices.Visible && badges.AbsenceNotices.Count > 0 {
return true
}
if badges.ChatMessages.Visible && badges.ChatMessages.Count > 0 {
return true
}
if badges.MessageResponses.Visible && badges.MessageResponses.Count > 0 {
return true
}
if badges.LetterResponses.Visible && badges.LetterResponses.Count > 0 {
return true
}
if badges.GroupMessages.Visible && badges.GroupMessages.Count > 0 {
return true
}
if badges.LocationMessages.Visible && badges.LocationMessages.Count > 0 {
return true
}
return false
}
// computeTotalUnread calculates the total unread count across all visible badges
func (s *NotificationBadgeService) computeTotalUnread(badges NotificationBadges) int {
total := 0
if badges.News.Visible {
total += badges.News.Count
}
if badges.Letters.Visible {
total += badges.Letters.Count
}
if badges.Messages.Visible {
total += badges.Messages.Count
}
if badges.PendingActions.Visible {
total += badges.PendingActions.Count
}
if badges.AbsenceNotices.Visible {
total += badges.AbsenceNotices.Count
}
if badges.ChatMessages.Visible {
total += badges.ChatMessages.Count
}
if badges.MessageResponses.Visible {
total += badges.MessageResponses.Count
}
if badges.LetterResponses.Visible {
total += badges.LetterResponses.Count
}
if badges.GroupMessages.Visible {
total += badges.GroupMessages.Count
}
if badges.LocationMessages.Visible {
total += badges.LocationMessages.Count
}
return total
}
package service
import (
"fmt"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// NotifyParentsOfLetter notifies all parents that a new letter has been published
func NotifyParentsOfLetter(db *gorm.DB, letter *model.ParentalLetter) error {
recipients, err := GetLetterRecipients(db, letter)
if err != nil {
return fmt.Errorf("failed to get recipients: %w", err)
}
now := time.Now()
for _, recipient := range recipients {
// Create or update notification record
var letterRead model.ParentalLetterRead
err := db.Where("letter_id = ? AND user_id = ?", letter.ID, recipient.ID).
First(&letterRead).Error
if err == gorm.ErrRecordNotFound {
// Create new record
letterRead = model.ParentalLetterRead{
LetterId: letter.ID,
UserId: recipient.ID,
NotifiedAt: &now,
}
if err := db.Create(&letterRead).Error; err != nil {
logger.Error("letter notify: failed to create letter-read record",
"letterID", letter.ID,
"userID", recipient.ID,
"error", err)
}
} else if err != nil {
logger.Error("letter notify: failed to check notification status",
"letterID", letter.ID,
"userID", recipient.ID,
"error", err)
} else {
// Update notification time if not set
if letterRead.NotifiedAt == nil {
letterRead.NotifiedAt = &now
if err := db.Save(&letterRead).Error; err != nil {
logger.Error("letter notify: failed to update letter-read record",
"letterID", letter.ID,
"userID", recipient.ID,
"error", err)
}
}
}
}
return nil
}
// NotifyReviewerOfLetter notifies a reviewer that a letter needs review
func NotifyReviewerOfLetter(db *gorm.DB, letter *model.ParentalLetter) error {
if letter.ReviewerId == nil {
return nil // No reviewer assigned
}
var reviewer model.User
if err := db.First(&reviewer, *letter.ReviewerId).Error; err != nil {
return fmt.Errorf("failed to load reviewer: %w", err)
}
// Send review request email (always sent, not filtered by notification opt-in)
lang := reviewer.Language
if lang == "" {
lang = "de"
}
if err := SendReviewRequestEmail(reviewer.Email, reviewer.FirstName, letter.Subject, fmt.Sprintf("%d", letter.ID), lang); err != nil {
logger.Error("letter notify: failed to send review request email",
"letterID", letter.ID,
"to", reviewer.Email,
"error", err)
}
return nil
}
package service
import (
"fmt"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// CanUserCreateLetter checks if a user can create a parental letter for the specified scope.
// Returns true if the user is directly authorised, or if they currently hold an
// active stand-in (Stellvertretung) from a lead with authority over that scope.
func CanUserCreateLetter(db *gorm.DB, user *model.User, groupId, locationId *uint) bool {
ok, _ := AuthorizeCreateLetter(db, user, groupId, locationId)
return ok
}
// AuthorizeCreateLetter returns whether the user can create a letter for the
// given scope, and, if authorisation comes from a stand-in, the lead User on
// whose behalf the actor is acting. standInFor is nil for direct authorisation.
func AuthorizeCreateLetter(db *gorm.DB, user *model.User, groupId, locationId *uint) (ok bool, standInFor *model.User) {
if canUserCreateLetterAsLead(db, user, groupId, locationId) {
return true, nil
}
standIn, err := model.HasActiveStandInFrom(db, user.ID, groupId, locationId)
if err == nil && standIn != nil {
return true, standIn
}
return false, nil
}
// canUserCreateLetterAsLead is the original direct-authorisation check (no stand-in fallback).
// This considers effective roles - substitutes are downgraded to Employee at non-home locations.
func canUserCreateLetterAsLead(db *gorm.DB, user *model.User, groupId, locationId *uint) bool {
// Admin can create any letter
if user.IsAdmin() {
return true
}
// Determine the target location for effective role check
var targetLocationID uint
if locationId != nil && *locationId > 0 {
targetLocationID = *locationId
} else if groupId != nil && *groupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *groupId).Error; err != nil {
return false
}
targetLocationID = group.LocationId
} else {
return false // No target specified
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot create letters
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false // Employee cannot create letters
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can create for their location or any group in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can create for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If creating for a specific group, verify user is assigned to it
if groupId != nil && *groupId > 0 {
for _, g := range userGroups {
if g.ID == *groupId {
return true
}
}
return false
}
// If creating for location-wide (no specific group), verify location matches user's groups
for _, g := range userGroups {
if g.LocationId == targetLocationID {
return true
}
}
return false
}
return false
}
// CanUserEditLetter checks if a user can edit a specific parental letter
// This considers effective roles - substitutes are downgraded to Employee at non-home locations
func CanUserEditLetter(db *gorm.DB, user *model.User, letter *model.ParentalLetter) bool {
// Can only edit if status is draft or pending_review
if letter.ReviewStatus != "draft" && letter.ReviewStatus != "pending_review" {
return false
}
// Admin can edit any letter
if user.IsAdmin() {
return true
}
// Author can always edit their own letter (if in editable status)
if letter.CreatedById == user.ID {
return true
}
// Delegatee can edit if assigned to them
if letter.DelegatedToId != nil && *letter.DelegatedToId == user.ID {
return true
}
// Determine the target location for effective role check
targetLocationID := letter.LocationId
if letter.GroupId != nil && *letter.GroupId > 0 {
var group model.Group
if err := db.Select("location_id").First(&group, *letter.GroupId).Error; err == nil {
targetLocationID = group.LocationId
}
}
// Check effective role at the target location
// Substitutes are downgraded to Employee and cannot edit letters
effectiveRole := user.GetEffectiveRoleForLocation(db, targetLocationID)
if effectiveRole.Role == "Employee" || effectiveRole.Role == "Parent" || effectiveRole.Role == "Anonymous" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if target location is in user's assigned locations
hasAccess := false
for _, locID := range userLocationIDs {
if targetLocationID == locID {
hasAccess = true
break
}
}
if !hasAccess {
return false
}
// LocationLead (effective) can edit location-wide or group letters in their location
if effectiveRole.Role == "LocationLead" || effectiveRole.Role == "Admin" {
return true
}
// GroupLead (effective) can edit letters for their core team groups only
// (not for groups they're substituting in)
if effectiveRole.Role == "GroupLead" {
userGroups, err := user.GetEmployeeCoreTeamGroups(db)
if err != nil {
return false
}
// If letter is for a specific group, verify user is assigned to it
if letter.GroupId != nil && *letter.GroupId > 0 {
for _, g := range userGroups {
if g.ID == *letter.GroupId {
return true
}
}
return false
}
// If letter is location-wide, check if location matches user's groups
for _, g := range userGroups {
if g.LocationId == letter.LocationId {
return true
}
}
return false
}
return false
}
// CanAdminEditLetter checks if an admin can edit a parental letter
// Admins can edit drafts, pending_review, and published letters that haven't been read yet
func CanAdminEditLetter(db *gorm.DB, letter *model.ParentalLetter) bool {
// Can always edit drafts and pending_review
if letter.Draft || letter.ReviewStatus == "draft" || letter.ReviewStatus == "pending_review" {
return true
}
// For published: only if no one has read it yet
if letter.ReviewStatus == "published" {
var readCount int64
db.Model(&model.ParentalLetterRead{}).
Where("letter_id = ? AND read_at IS NOT NULL", letter.ID).
Count(&readCount)
return readCount == 0
}
return false
}
// GetLetterRecipients returns all parent users who should receive the letter
func GetLetterRecipients(db *gorm.DB, letter *model.ParentalLetter) ([]model.User, error) {
var userIDs []uint
if letter.GroupId != nil && *letter.GroupId > 0 {
// Get all parent user IDs with children in this group
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_children ON user_children.user_id = users.id").
Joins("JOIN children ON children.id = user_children.child_id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Parent").
Where("children.group_id = ?", *letter.GroupId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get group recipient IDs: %w", err)
}
} else if letter.LocationId > 0 {
// Get all parent user IDs with children in this location
err := db.Model(&model.User{}).
Distinct("users.id").
Joins("JOIN user_children ON user_children.user_id = users.id").
Joins("JOIN children ON children.id = user_children.child_id").
Joins("JOIN groups ON groups.id = children.group_id").
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id AND roles.name = ?", "Parent").
Where("groups.location_id = ?", letter.LocationId).
Where("users.deleted_at IS NULL").
Pluck("users.id", &userIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to get location recipient IDs: %w", err)
}
}
if len(userIDs) == 0 {
return []model.User{}, nil
}
// Fetch users with Children preloaded for DisplayNameWithChildren()
var recipients []model.User
err := db.Preload("Children").Where("id IN ?", userIDs).Find(&recipients).Error
if err != nil {
return nil, fmt.Errorf("failed to load recipients with children: %w", err)
}
return recipients, nil
}
// RequiresReview checks if a letter requires review before publishing
func RequiresReview(letter *model.ParentalLetter) bool {
return letter.ReviewerId != nil
}
// CanUserReviewLetter checks if a user can review a specific letter
func CanUserReviewLetter(user *model.User, letter *model.ParentalLetter) bool {
// If a specific reviewer is assigned, only they can review
if letter.ReviewerId != nil && *letter.ReviewerId == user.ID {
return true
}
// If letter is delegated, the delegator (creator) can review it
if letter.DelegatedToId != nil && letter.CreatedById == user.ID {
return true
}
return false
}
// GetPotentialReviewers returns all users who could review a letter
func GetPotentialReviewers(db *gorm.DB, user *model.User) ([]model.User, error) {
var reviewers []model.User
// Get all users with Employee role or higher (excluding Parents)
err := db.Distinct().
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead", "Admin"}).
Where("users.id != ?", user.ID). // Exclude the current user
Where("users.deleted_at IS NULL").
Order("users.email").
Find(&reviewers).Error
if err != nil {
return nil, fmt.Errorf("failed to get potential reviewers: %w", err)
}
return reviewers, nil
}
// MarkLetterAsRead marks a letter as read by a specific user
func MarkLetterAsRead(db *gorm.DB, letterId, userId uint) error {
var letterRead model.ParentalLetterRead
// Check if already read
err := db.Where("letter_id = ? AND user_id = ?", letterId, userId).
First(&letterRead).Error
if err == gorm.ErrRecordNotFound {
// Create new read record
now := time.Now()
letterRead = model.ParentalLetterRead{
LetterId: letterId,
UserId: userId,
ReadAt: &now,
}
return db.Create(&letterRead).Error
} else if err != nil {
return fmt.Errorf("failed to check letter read status: %w", err)
}
// Update read time if not set
if letterRead.ReadAt == nil {
now := time.Now()
letterRead.ReadAt = &now
return db.Save(&letterRead).Error
}
return nil
}
// GetReadStatistics returns lists of users who have read and not read a letter
func GetReadStatistics(db *gorm.DB, letterId uint) (read []model.User, unread []model.User, err error) {
var letter model.ParentalLetter
err = db.Preload("Group").Preload("Location").First(&letter, letterId).Error
if err != nil {
return nil, nil, fmt.Errorf("failed to load letter: %w", err)
}
// Get all recipients
recipients, err := GetLetterRecipients(db, &letter)
if err != nil {
return nil, nil, err
}
// Get all read records for this letter
var readRecords []model.ParentalLetterRead
err = db.Where("letter_id = ? AND read_at IS NOT NULL", letterId).Find(&readRecords).Error
if err != nil {
return nil, nil, fmt.Errorf("failed to get read records: %w", err)
}
// Create map of users who have read
readUserIds := make(map[uint]bool)
for _, record := range readRecords {
readUserIds[record.UserId] = true
}
// Separate into read and unread lists
for _, recipient := range recipients {
if readUserIds[recipient.ID] {
read = append(read, recipient)
} else {
unread = append(unread, recipient)
}
}
// Sort both lists alphabetically by last name, then first name
sortUsersByName(read)
sortUsersByName(unread)
return read, unread, nil
}
// CanUserViewLetter checks if a user can view a specific parental letter.
// Used for access control when viewing letters, downloading attachments,
// or submitting responses (answers, polls, table cells, survey, table
// entries).
//
// Audit #464 flagged that parent access was being re-implemented
// inline in 10 spots across parental_letters.go (7 hand-rolled
// JOIN user_children checks + 3 endpoints with no check at all),
// mirroring the same bug class as the GetNewsDetail scope leak fixed
// in #468. Routing every parent endpoint through this helper makes the
// rule auditable in one place.
//
// Parent access rule: the parent has at least one child in the
// letter's target scope — its specific group (when GroupId is set) or
// any group at its location (when GroupId is nil).
func CanUserViewLetter(db *gorm.DB, user *model.User, letter *model.ParentalLetter) bool {
// Admin can view any letter
if user.IsAdmin() {
return true
}
// Author can always view their own letter
if letter.CreatedById == user.ID {
return true
}
// Delegatee can view if assigned to them
if letter.DelegatedToId != nil && *letter.DelegatedToId == user.ID {
return true
}
// Reviewer can view if assigned as reviewer
if letter.ReviewerId != nil && *letter.ReviewerId == user.ID {
return true
}
// Employees can view published letters for their groups/location
if user.IsEmployee() {
// Only published letters are visible to employees
if letter.ReviewStatus != "published" && letter.ReviewStatus != "approved" {
return false
}
// Get user's assigned location IDs
userLocationIDs, err := user.GetLocationIDs(db)
if err != nil || len(userLocationIDs) == 0 {
return false
}
// Check if letter's location matches user's assigned locations
for _, locID := range userLocationIDs {
if letter.LocationId == locID {
return true
}
}
// If letter is for a specific group, check if user is assigned to that group
if letter.GroupId != nil && *letter.GroupId > 0 {
var count int64
db.Table("group_teachers").
Where("user_id = ? AND group_id = ?", user.ID, *letter.GroupId).
Count(&count)
return count > 0
}
}
// Parents can view letters targeting their children's group or
// location. Mirrors the per-endpoint hand-rolled checks that used
// to live in the controller.
if user.IsParent() {
var count int64
if letter.GroupId != nil {
db.Model(&model.Child{}).
Joins("JOIN user_children ON user_children.child_id = children.id").
Where("user_children.user_id = ?", user.ID).
Where("children.group_id = ?", letter.GroupId).
Count(&count)
} else {
db.Model(&model.Child{}).
Joins("JOIN user_children ON user_children.child_id = children.id").
Joins("JOIN groups ON groups.id = children.group_id").
Where("user_children.user_id = ?", user.ID).
Where("groups.location_id = ?", letter.LocationId).
Count(&count)
}
return count > 0
}
return false
}
package service
import (
"fmt"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// =============== POLL FUNCTIONS ===============
// PollOptionResult represents a poll option with its vote count
type PollOptionResult struct {
Option model.PollOption
VoteCount int
Percentage float64
Voters []model.User // Only populated if not anonymous
}
// CreatePollOptions creates poll options for a letter
func CreatePollOptions(db *gorm.DB, letterId uint, options []string) error {
for i, text := range options {
if text == "" {
continue
}
option := model.PollOption{
LetterId: &letterId,
Text: text,
SortOrder: i,
}
if err := db.Create(&option).Error; err != nil {
return fmt.Errorf("failed to create poll option: %w", err)
}
}
return nil
}
// GetPollOptions returns all poll options for a letter
func GetPollOptions(db *gorm.DB, letterId uint) ([]model.PollOption, error) {
var options []model.PollOption
err := db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Find(&options).Error
if err != nil {
return nil, fmt.Errorf("failed to get poll options: %w", err)
}
return options, nil
}
// GetPollResults returns poll results with vote counts
func GetPollResults(db *gorm.DB, letterId uint, isAnonymous bool) ([]PollOptionResult, int, error) {
options, err := GetPollOptions(db, letterId)
if err != nil {
return nil, 0, err
}
// Get total vote count (unique users who voted)
var totalVoters int64
err = db.Model(&model.PollVote{}).
Where("letter_id = ?", letterId).
Distinct("user_id").
Count(&totalVoters).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to count total voters: %w", err)
}
results := make([]PollOptionResult, len(options))
for i, option := range options {
// Count votes for this option
var voteCount int64
err = db.Model(&model.PollVote{}).
Where("option_id = ?", option.ID).
Count(&voteCount).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to count votes: %w", err)
}
results[i] = PollOptionResult{
Option: option,
VoteCount: int(voteCount),
}
// Calculate percentage (based on total voters, not total votes)
if totalVoters > 0 {
results[i].Percentage = float64(voteCount) / float64(totalVoters) * 100
}
// If not anonymous, load voters for this option
if !isAnonymous {
var votes []model.PollVote
err = db.Preload("User").
Preload("User.Children").
Where("option_id = ?", option.ID).
Find(&votes).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to load voters: %w", err)
}
for _, vote := range votes {
results[i].Voters = append(results[i].Voters, vote.User)
}
}
}
return results, int(totalVoters), nil
}
// SubmitPollVote submits a user's vote for poll options
func SubmitPollVote(db *gorm.DB, letterId uint, userId uint, optionIds []uint, isSingleChoice bool) error {
// Check if user has already voted
if HasUserVoted(db, letterId, userId) {
return fmt.Errorf("user has already voted")
}
// Validate option IDs belong to this letter
for _, optionId := range optionIds {
var option model.PollOption
err := db.Where("id = ? AND letter_id = ?", optionId, letterId).First(&option).Error
if err != nil {
return fmt.Errorf("invalid option ID %d: %w", optionId, err)
}
}
// For single choice, ensure only one option
if isSingleChoice && len(optionIds) > 1 {
return fmt.Errorf("single choice poll allows only one option")
}
// Create votes
for _, optionId := range optionIds {
vote := model.PollVote{
OptionId: optionId,
UserId: userId,
LetterId: letterId,
}
if err := db.Create(&vote).Error; err != nil {
return fmt.Errorf("failed to create vote: %w", err)
}
}
return nil
}
// HasUserVoted checks if a user has already voted on a poll
func HasUserVoted(db *gorm.DB, letterId uint, userId uint) bool {
var count int64
db.Model(&model.PollVote{}).
Where("letter_id = ? AND user_id = ?", letterId, userId).
Count(&count)
return count > 0
}
// GetUserVotes returns the option IDs a user voted for
func GetUserVotes(db *gorm.DB, letterId uint, userId uint) ([]uint, error) {
var votes []model.PollVote
err := db.Where("letter_id = ? AND user_id = ?", letterId, userId).Find(&votes).Error
if err != nil {
return nil, fmt.Errorf("failed to get user votes: %w", err)
}
optionIds := make([]uint, len(votes))
for i, vote := range votes {
optionIds[i] = vote.OptionId
}
return optionIds, nil
}
// DeletePollOptions deletes all poll options and votes for a letter
func DeletePollOptions(db *gorm.DB, letterId uint) error {
// First delete all votes
if err := db.Where("letter_id = ?", letterId).Delete(&model.PollVote{}).Error; err != nil {
return fmt.Errorf("failed to delete poll votes: %w", err)
}
// Then delete options
if err := db.Where("letter_id = ?", letterId).Delete(&model.PollOption{}).Error; err != nil {
return fmt.Errorf("failed to delete poll options: %w", err)
}
return nil
}
// HasPollVotes checks if a poll has any votes
func HasPollVotes(db *gorm.DB, letterId uint) bool {
var count int64
db.Model(&model.PollVote{}).Where("letter_id = ?", letterId).Count(&count)
return count > 0
}
// =============== TABLE FUNCTIONS ===============
// TableData represents a complete table structure with all cells
type TableData struct {
Columns []model.TableColumn
Rows []model.TableRow
Cells map[uint]map[uint][]TableCellWithUser // rowId -> columnId -> cells
}
// TableCellWithUser represents a cell value with its submitter
type TableCellWithUser struct {
Cell model.TableCell
User model.User
}
// CreateTableStructure creates columns and rows for a table letter
func CreateTableStructure(db *gorm.DB, letterId uint, columns []string, rows []string) error {
// Create columns
for i, name := range columns {
if name == "" {
continue
}
column := model.TableColumn{
LetterId: letterId,
Name: name,
SortOrder: i,
}
if err := db.Create(&column).Error; err != nil {
return fmt.Errorf("failed to create table column: %w", err)
}
}
// Create rows
for i, label := range rows {
if label == "" {
continue
}
row := model.TableRow{
LetterId: letterId,
Label: label,
SortOrder: i,
}
if err := db.Create(&row).Error; err != nil {
return fmt.Errorf("failed to create table row: %w", err)
}
}
return nil
}
// GetTableData returns the complete table structure with all cells
func GetTableData(db *gorm.DB, letterId uint) (*TableData, error) {
data := &TableData{
Cells: make(map[uint]map[uint][]TableCellWithUser),
}
// Load columns
err := db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Find(&data.Columns).Error
if err != nil {
return nil, fmt.Errorf("failed to load table columns: %w", err)
}
// Load rows
err = db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Find(&data.Rows).Error
if err != nil {
return nil, fmt.Errorf("failed to load table rows: %w", err)
}
// Load all cells with users
var cells []model.TableCell
err = db.Preload("User").
Preload("User.Children").
Where("letter_id = ?", letterId).
Find(&cells).Error
if err != nil {
return nil, fmt.Errorf("failed to load table cells: %w", err)
}
// Organize cells by row and column
for _, cell := range cells {
if data.Cells[cell.RowId] == nil {
data.Cells[cell.RowId] = make(map[uint][]TableCellWithUser)
}
data.Cells[cell.RowId][cell.ColumnId] = append(
data.Cells[cell.RowId][cell.ColumnId],
TableCellWithUser{Cell: cell, User: cell.User},
)
}
return data, nil
}
// SubmitTableCell submits or updates a user's cell value
func SubmitTableCell(db *gorm.DB, letterId uint, rowId uint, columnId uint, userId uint, value string) error {
// Validate row belongs to this letter
var row model.TableRow
err := db.Where("id = ? AND letter_id = ?", rowId, letterId).First(&row).Error
if err != nil {
return fmt.Errorf("invalid row ID %d: %w", rowId, err)
}
// Validate column belongs to this letter
var column model.TableColumn
err = db.Where("id = ? AND letter_id = ?", columnId, letterId).First(&column).Error
if err != nil {
return fmt.Errorf("invalid column ID %d: %w", columnId, err)
}
// Check if user already has a cell value for this position
var existing model.TableCell
err = db.Where("letter_id = ? AND row_id = ? AND column_id = ? AND user_id = ?",
letterId, rowId, columnId, userId).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Create new cell
cell := model.TableCell{
LetterId: letterId,
RowId: rowId,
ColumnId: columnId,
UserId: userId,
Value: value,
}
return db.Create(&cell).Error
} else if err != nil {
return fmt.Errorf("failed to check existing cell: %w", err)
}
// Update existing cell
existing.Value = value
return db.Save(&existing).Error
}
// GetUserTableEntries returns all cells filled in by a user for a letter
func GetUserTableEntries(db *gorm.DB, letterId uint, userId uint) ([]model.TableCell, error) {
var cells []model.TableCell
err := db.Where("letter_id = ? AND user_id = ?", letterId, userId).Find(&cells).Error
if err != nil {
return nil, fmt.Errorf("failed to get user table entries: %w", err)
}
return cells, nil
}
// DeleteTableStructure deletes all columns, rows, and cells for a letter
func DeleteTableStructure(db *gorm.DB, letterId uint) error {
// Delete cells first
if err := db.Where("letter_id = ?", letterId).Delete(&model.TableCell{}).Error; err != nil {
return fmt.Errorf("failed to delete table cells: %w", err)
}
// Delete rows
if err := db.Where("letter_id = ?", letterId).Delete(&model.TableRow{}).Error; err != nil {
return fmt.Errorf("failed to delete table rows: %w", err)
}
// Delete columns
if err := db.Where("letter_id = ?", letterId).Delete(&model.TableColumn{}).Error; err != nil {
return fmt.Errorf("failed to delete table columns: %w", err)
}
return nil
}
// HasTableEntries checks if a table has any filled cells
func HasTableEntries(db *gorm.DB, letterId uint) bool {
var count int64
db.Model(&model.TableCell{}).Where("letter_id = ?", letterId).Count(&count)
return count > 0
}
// TableEntryWithUser represents a user-created table entry (row) with all its cell values
type TableEntryWithUser struct {
Row model.TableRow
User model.User
Values map[uint]string // columnId -> value
}
// CreateTableEntry creates a new row for a user and fills in cell values
// This is for tables without predefined rows where parents add their own entries
func CreateTableEntry(db *gorm.DB, letterId uint, userId uint, cellValues map[uint]string) error {
// Get user for row label
var user model.User
if err := db.First(&user, userId).Error; err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// Create a new row for this user
row := model.TableRow{
LetterId: letterId,
Label: user.DisplayName(),
UserId: &userId, // Track which user created this row
SortOrder: 0, // Will be ordered by creation time
}
if err := db.Create(&row).Error; err != nil {
return fmt.Errorf("failed to create table row: %w", err)
}
// Create cells for each column value
for columnId, value := range cellValues {
if value == "" {
continue
}
// Validate column belongs to this letter
var column model.TableColumn
if err := db.Where("id = ? AND letter_id = ?", columnId, letterId).First(&column).Error; err != nil {
return fmt.Errorf("invalid column ID %d: %w", columnId, err)
}
cell := model.TableCell{
LetterId: letterId,
RowId: row.ID,
ColumnId: columnId,
UserId: userId,
Value: value,
}
if err := db.Create(&cell).Error; err != nil {
return fmt.Errorf("failed to create table cell: %w", err)
}
}
return nil
}
// GetTableEntriesWithUsers returns all user-created entries for a table without predefined rows
func GetTableEntriesWithUsers(db *gorm.DB, letterId uint) ([]TableEntryWithUser, error) {
// Get all rows that have a user_id (user-created rows)
var rows []model.TableRow
err := db.Where("letter_id = ? AND user_id IS NOT NULL", letterId).
Order("created_at ASC").
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to load table rows: %w", err)
}
var entries []TableEntryWithUser
for _, row := range rows {
// Load the user who created this row
var user model.User
if err := db.Preload("Children").First(&user, *row.UserId).Error; err != nil {
continue // Skip if user not found
}
// Load cells for this row
var cells []model.TableCell
if err := db.Where("row_id = ?", row.ID).Find(&cells).Error; err != nil {
continue
}
entry := TableEntryWithUser{
Row: row,
User: user,
Values: make(map[uint]string),
}
for _, cell := range cells {
entry.Values[cell.ColumnId] = cell.Value
}
entries = append(entries, entry)
}
return entries, nil
}
// UpdateTableEntry updates an existing user-created entry
func UpdateTableEntry(db *gorm.DB, letterId uint, rowId uint, userId uint, cellValues map[uint]string) error {
// Verify the row belongs to this user and letter
var row model.TableRow
err := db.Where("id = ? AND letter_id = ? AND user_id = ?", rowId, letterId, userId).First(&row).Error
if err != nil {
return fmt.Errorf("row not found or not owned by user: %w", err)
}
// Update or create cells for each column value
for columnId, value := range cellValues {
// Validate column belongs to this letter
var column model.TableColumn
if err := db.Where("id = ? AND letter_id = ?", columnId, letterId).First(&column).Error; err != nil {
return fmt.Errorf("invalid column ID %d: %w", columnId, err)
}
// Check if cell exists
var existing model.TableCell
err = db.Where("letter_id = ? AND row_id = ? AND column_id = ? AND user_id = ?",
letterId, rowId, columnId, userId).First(&existing).Error
if err == gorm.ErrRecordNotFound {
if value != "" {
// Create new cell
cell := model.TableCell{
LetterId: letterId,
RowId: rowId,
ColumnId: columnId,
UserId: userId,
Value: value,
}
if err := db.Create(&cell).Error; err != nil {
return fmt.Errorf("failed to create cell: %w", err)
}
}
} else if err == nil {
// Update or delete existing cell
if value == "" {
db.Delete(&existing)
} else {
existing.Value = value
if err := db.Save(&existing).Error; err != nil {
return fmt.Errorf("failed to update cell: %w", err)
}
}
}
}
return nil
}
// DeleteTableEntry deletes a user-created entry (row and all its cells)
func DeleteTableEntry(db *gorm.DB, letterId uint, rowId uint, userId uint) error {
// Verify the row belongs to this user and letter
var row model.TableRow
err := db.Where("id = ? AND letter_id = ? AND user_id = ?", rowId, letterId, userId).First(&row).Error
if err != nil {
return fmt.Errorf("row not found or not owned by user: %w", err)
}
// Delete cells first
if err := db.Where("row_id = ?", rowId).Delete(&model.TableCell{}).Error; err != nil {
return fmt.Errorf("failed to delete cells: %w", err)
}
// Delete the row
if err := db.Delete(&row).Error; err != nil {
return fmt.Errorf("failed to delete row: %w", err)
}
return nil
}
// HasPredefinedRows checks if a table has predefined rows (rows without user_id)
func HasPredefinedRows(db *gorm.DB, letterId uint) bool {
var count int64
db.Model(&model.TableRow{}).Where("letter_id = ? AND user_id IS NULL", letterId).Count(&count)
return count > 0
}
// GetUserTableEntry returns the user's own entry row ID if they have one (for tables without predefined rows)
func GetUserTableEntry(db *gorm.DB, letterId uint, userId uint) (*model.TableRow, error) {
var row model.TableRow
err := db.Where("letter_id = ? AND user_id = ?", letterId, userId).First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// GetTableColumns returns columns for a letter
func GetTableColumns(db *gorm.DB, letterId uint) ([]model.TableColumn, error) {
var columns []model.TableColumn
err := db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Find(&columns).Error
if err != nil {
return nil, fmt.Errorf("failed to get table columns: %w", err)
}
return columns, nil
}
// GetTableRows returns rows for a letter
func GetTableRows(db *gorm.DB, letterId uint) ([]model.TableRow, error) {
var rows []model.TableRow
err := db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to get table rows: %w", err)
}
return rows, nil
}
// =============== SURVEY FUNCTIONS ===============
// QuestionInput represents input data for creating a survey question
type QuestionInput struct {
Text string // Question text
QuestionType string // "single_choice", "multi_choice", "free_text"
Options []string // Answer options (for choice types)
}
// SurveyQuestionResult represents a question with its response statistics
type SurveyQuestionResult struct {
Question model.SurveyQuestion
OptionResults []SurveyOptionResult // For choice questions
FreeTextAnswers []SurveyFreeTextAnswer // For free text questions
TotalResponses int
}
// SurveyOptionResult represents an option with its vote count
type SurveyOptionResult struct {
Option model.PollOption
VoteCount int
Percentage float64
Voters []model.User // Only populated if not anonymous
}
// SurveyFreeTextAnswer represents a free text response
type SurveyFreeTextAnswer struct {
Answer string
User model.User // Only populated if not anonymous
}
// ResponseInput represents input data for submitting a survey response
type ResponseInput struct {
QuestionId uint
OptionIds []uint // For choice questions
FreeTextAnswer string // For free text questions
}
// CreateSurveyQuestions creates survey questions with their options for a letter
func CreateSurveyQuestions(db *gorm.DB, letterId uint, questions []QuestionInput) error {
for i, q := range questions {
if q.Text == "" {
continue
}
// Validate question type
validTypes := map[string]bool{"single_choice": true, "multi_choice": true, "free_text": true}
if !validTypes[q.QuestionType] {
return fmt.Errorf("invalid question type: %s", q.QuestionType)
}
question := model.SurveyQuestion{
LetterId: letterId,
Text: q.Text,
QuestionType: q.QuestionType,
SortOrder: i,
}
if err := db.Create(&question).Error; err != nil {
return fmt.Errorf("failed to create survey question: %w", err)
}
// Create options for choice questions
if q.QuestionType != "free_text" {
for j, optText := range q.Options {
if optText == "" {
continue
}
option := model.PollOption{
QuestionId: &question.ID,
Text: optText,
SortOrder: j,
}
if err := db.Create(&option).Error; err != nil {
return fmt.Errorf("failed to create survey option: %w", err)
}
}
}
}
return nil
}
// GetSurveyQuestions returns all survey questions with their options for a letter
func GetSurveyQuestions(db *gorm.DB, letterId uint) ([]model.SurveyQuestion, error) {
var questions []model.SurveyQuestion
err := db.Where("letter_id = ?", letterId).
Order("sort_order ASC").
Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order ASC")
}).
Find(&questions).Error
if err != nil {
return nil, fmt.Errorf("failed to get survey questions: %w", err)
}
return questions, nil
}
// DeleteSurveyQuestions deletes all survey questions, options, and responses for a letter
func DeleteSurveyQuestions(db *gorm.DB, letterId uint) error {
// Get all question IDs for this letter
var questionIds []uint
if err := db.Model(&model.SurveyQuestion{}).Where("letter_id = ?", letterId).Pluck("id", &questionIds).Error; err != nil {
return fmt.Errorf("failed to get question IDs: %w", err)
}
if len(questionIds) == 0 {
return nil
}
// Delete responses
if err := db.Where("question_id IN ?", questionIds).Delete(&model.SurveyResponse{}).Error; err != nil {
return fmt.Errorf("failed to delete survey responses: %w", err)
}
// Delete options
if err := db.Where("question_id IN ?", questionIds).Delete(&model.PollOption{}).Error; err != nil {
return fmt.Errorf("failed to delete survey options: %w", err)
}
// Delete questions
if err := db.Where("letter_id = ?", letterId).Delete(&model.SurveyQuestion{}).Error; err != nil {
return fmt.Errorf("failed to delete survey questions: %w", err)
}
return nil
}
// SubmitSurveyResponses submits a user's responses to all survey questions
func SubmitSurveyResponses(db *gorm.DB, letterId uint, userId uint, responses []ResponseInput) error {
// Check if user has already responded to this survey
if HasUserRespondedToSurvey(db, letterId, userId) {
return fmt.Errorf("user has already responded to this survey")
}
for _, resp := range responses {
// Validate question belongs to this letter
var question model.SurveyQuestion
if err := db.Where("id = ? AND letter_id = ?", resp.QuestionId, letterId).First(&question).Error; err != nil {
return fmt.Errorf("invalid question ID %d: %w", resp.QuestionId, err)
}
if question.QuestionType == "free_text" {
// Create a single response with free text
response := model.SurveyResponse{
QuestionId: resp.QuestionId,
UserId: userId,
LetterId: letterId,
FreeTextAnswer: resp.FreeTextAnswer,
}
if err := db.Create(&response).Error; err != nil {
return fmt.Errorf("failed to create free text response: %w", err)
}
} else {
// Validate option IDs belong to this question
for _, optionId := range resp.OptionIds {
var option model.PollOption
if err := db.Where("id = ? AND question_id = ?", optionId, resp.QuestionId).First(&option).Error; err != nil {
return fmt.Errorf("invalid option ID %d: %w", optionId, err)
}
}
// For single choice, ensure only one option
if question.QuestionType == "single_choice" && len(resp.OptionIds) > 1 {
return fmt.Errorf("single choice question allows only one option")
}
// Create responses for each selected option
for _, optionId := range resp.OptionIds {
response := model.SurveyResponse{
QuestionId: resp.QuestionId,
OptionId: &optionId,
UserId: userId,
LetterId: letterId,
}
if err := db.Create(&response).Error; err != nil {
return fmt.Errorf("failed to create choice response: %w", err)
}
}
}
}
return nil
}
// GetSurveyResults returns survey results grouped by question
func GetSurveyResults(db *gorm.DB, letterId uint, isAnonymous bool) ([]SurveyQuestionResult, int, error) {
questions, err := GetSurveyQuestions(db, letterId)
if err != nil {
return nil, 0, err
}
// Get total unique respondents
var totalRespondents int64
err = db.Model(&model.SurveyResponse{}).
Where("letter_id = ?", letterId).
Distinct("user_id").
Count(&totalRespondents).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to count total respondents: %w", err)
}
results := make([]SurveyQuestionResult, len(questions))
for i, question := range questions {
result := SurveyQuestionResult{
Question: question,
}
if question.QuestionType == "free_text" {
// Get free text answers
var responses []model.SurveyResponse
query := db.Where("question_id = ?", question.ID)
if !isAnonymous {
query = query.Preload("User").Preload("User.Children")
}
if err := query.Find(&responses).Error; err != nil {
return nil, 0, fmt.Errorf("failed to get free text responses: %w", err)
}
for _, resp := range responses {
answer := SurveyFreeTextAnswer{
Answer: resp.FreeTextAnswer,
}
if !isAnonymous {
answer.User = resp.User
}
result.FreeTextAnswers = append(result.FreeTextAnswers, answer)
}
result.TotalResponses = len(responses)
} else {
// Get option results
for _, option := range question.Options {
var voteCount int64
err = db.Model(&model.SurveyResponse{}).
Where("option_id = ?", option.ID).
Count(&voteCount).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to count votes: %w", err)
}
optResult := SurveyOptionResult{
Option: option,
VoteCount: int(voteCount),
}
// Calculate percentage based on question respondents
var questionRespondents int64
db.Model(&model.SurveyResponse{}).
Where("question_id = ?", question.ID).
Distinct("user_id").
Count(&questionRespondents)
if questionRespondents > 0 {
optResult.Percentage = float64(voteCount) / float64(questionRespondents) * 100
}
// Load voters if not anonymous
if !isAnonymous {
var responses []model.SurveyResponse
db.Preload("User").Preload("User.Children").
Where("option_id = ?", option.ID).
Find(&responses)
for _, resp := range responses {
optResult.Voters = append(optResult.Voters, resp.User)
}
}
result.OptionResults = append(result.OptionResults, optResult)
}
// Count unique respondents for this question
var qRespondents int64
db.Model(&model.SurveyResponse{}).
Where("question_id = ?", question.ID).
Distinct("user_id").
Count(&qRespondents)
result.TotalResponses = int(qRespondents)
}
results[i] = result
}
return results, int(totalRespondents), nil
}
// HasSurveyResponses checks if a survey has any responses
func HasSurveyResponses(db *gorm.DB, letterId uint) bool {
var count int64
db.Model(&model.SurveyResponse{}).Where("letter_id = ?", letterId).Count(&count)
return count > 0
}
// HasUserRespondedToSurvey checks if a user has already responded to a survey
func HasUserRespondedToSurvey(db *gorm.DB, letterId uint, userId uint) bool {
var count int64
db.Model(&model.SurveyResponse{}).
Where("letter_id = ? AND user_id = ?", letterId, userId).
Count(&count)
return count > 0
}
// GetUserSurveyResponses returns all responses a user submitted for a survey
func GetUserSurveyResponses(db *gorm.DB, letterId uint, userId uint) ([]model.SurveyResponse, error) {
var responses []model.SurveyResponse
err := db.Where("letter_id = ? AND user_id = ?", letterId, userId).Find(&responses).Error
if err != nil {
return nil, fmt.Errorf("failed to get user survey responses: %w", err)
}
return responses, nil
}
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"log"
"log/slog"
"strings"
"testing"
"time"
"wippidu_app_backend/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// RegistrationService handles self-registration workflows
type RegistrationService struct {
db *gorm.DB
}
// NewRegistrationService creates a new registration service
func NewRegistrationService(db *gorm.DB) *RegistrationService {
return &RegistrationService{db: db}
}
// RegistrationInput contains the data submitted in a registration form
type RegistrationInput struct {
FirstName string
LastName string
Email string
RequestType model.RegistrationRequestType // "parent" or "employee"
ChildFirstName string
ChildLastName string
ChildBirthday *time.Time
LocationID uint
GroupID *uint
RelationshipRole model.RelationshipRole
HoneypotField string // If filled, request is from a bot
// Pre-matched child from parent invitation code
InvitationChildID *uint // Child ID from invitation code (skips matching)
// Pre-matched employee from employee invitation code
EmployeeInvitationSyncEmployeeID *uint // SyncEmployee ID from employee invitation code
EmployeeExternalID *string // ExternalID to link User to SyncEmployee
}
// CreateRegistrationRequest creates a new registration request, user account, and sends verification email.
// The user account is created immediately but with Activated=false and Approved=false.
// User can log in after email verification but will see "pending approval" until admin approves.
func (s *RegistrationService) CreateRegistrationRequest(input RegistrationInput) (*model.RegistrationRequest, error) {
slog.Debug("registration-service: CreateRegistrationRequest started",
"email", input.Email,
"firstName", input.FirstName,
"lastName", input.LastName,
"requestType", input.RequestType,
"locationID", input.LocationID,
"hasInvitationChildID", input.InvitationChildID != nil,
"hasEmployeeSyncID", input.EmployeeInvitationSyncEmployeeID != nil,
)
// Check for honeypot - silently succeed but don't create record
if input.HoneypotField != "" {
slog.Debug("registration-service: honeypot triggered, returning fake success",
"email", input.Email,
)
// Return a fake request to indicate "success" to the bot
return &model.RegistrationRequest{}, nil
}
// Normalize email
email := strings.ToLower(strings.TrimSpace(input.Email))
slog.Debug("registration-service: email normalized",
"originalEmail", input.Email,
"normalizedEmail", email,
)
// Check for existing user with same email
var existingUser model.User
if err := s.db.Where("email = ?", email).First(&existingUser).Error; err == nil {
slog.Debug("registration-service: existing user found",
"email", email,
"existingUserID", existingUser.ID,
"activated", existingUser.Activated,
"approved", existingUser.Approved,
)
// User exists - check if deactivated (potential reregistration)
if existingUser.Activated {
slog.Debug("registration-service: user already activated, rejecting",
"email", email,
)
return nil, errors.New("email already registered")
}
slog.Debug("registration-service: user exists but not activated, will reactivate",
"email", email,
)
// Fall through - will update existing deactivated user
} else {
slog.Debug("registration-service: no existing user found",
"email", email,
)
}
var request *model.RegistrationRequest
var activationCode string
var userEmail, firstName, userLang string
slog.Debug("registration-service: starting transaction",
"email", email,
)
err := s.db.Transaction(func(tx *gorm.DB) error {
// Generate activation code
var err error
activationCode, err = generateActivationCode()
if err != nil {
slog.Debug("registration-service: failed to generate activation code",
"error", err.Error(),
"email", email,
)
return err
}
slog.Debug("registration-service: activation code generated",
"email", email,
"codePrefix", activationCode[:8]+"...",
)
var user *model.User
var existingUserID *uint
// Auto-approve if registering via invitation code (parent or employee)
autoApproved := input.InvitationChildID != nil || input.EmployeeInvitationSyncEmployeeID != nil
slog.Debug("registration-service: auto-approval determination",
"email", email,
"autoApproved", autoApproved,
"hasInvitationChildID", input.InvitationChildID != nil,
"hasEmployeeSyncID", input.EmployeeInvitationSyncEmployeeID != nil,
)
// Check for existing deactivated user
if err := tx.Where("email = ?", email).First(&existingUser).Error; err == nil && !existingUser.Activated {
slog.Debug("registration-service: reactivating existing user",
"email", email,
"userID", existingUser.ID,
)
// Reactivation of existing user - update their info
existingUserID = &existingUser.ID
existingUser.FirstName = strings.TrimSpace(input.FirstName)
existingUser.LastName = strings.TrimSpace(input.LastName)
existingUser.ActivateCode = &activationCode
// Auto-approve reactivation if via invitation code
if autoApproved {
now := time.Now()
existingUser.Approved = true
existingUser.ApprovedAt = &now
slog.Debug("registration-service: reactivating with auto-approval",
"email", email,
)
} else {
existingUser.Approved = false // Needs new approval
slog.Debug("registration-service: reactivating without auto-approval",
"email", email,
)
}
// Set ExternalID if employee invitation
if input.EmployeeExternalID != nil {
existingUser.ExternalID = input.EmployeeExternalID
slog.Debug("registration-service: setting ExternalID on existing user",
"email", email,
"externalID", *input.EmployeeExternalID,
)
}
if err := tx.Save(&existingUser).Error; err != nil {
slog.Debug("registration-service: failed to save existing user",
"error", err.Error(),
"email", email,
)
return err
}
slog.Debug("registration-service: existing user updated",
"email", email,
"userID", existingUser.ID,
)
user = &existingUser
} else {
slog.Debug("registration-service: creating new user",
"email", email,
)
var approvedAt *time.Time
if autoApproved {
now := time.Now()
approvedAt = &now
}
// Create new user account immediately
user = &model.User{
Email: email,
FirstName: strings.TrimSpace(input.FirstName),
LastName: strings.TrimSpace(input.LastName),
ActivateCode: &activationCode,
Activated: false, // Not yet verified email
Approved: autoApproved, // Auto-approve if via invitation code
ApprovedAt: approvedAt, // ApprovedByID stays nil (system auto-approved)
Language: "de",
ExternalID: input.EmployeeExternalID, // Link to SyncEmployee if employee invitation
}
slog.Debug("registration-service: creating user record",
"email", email,
"autoApproved", autoApproved,
"hasExternalID", input.EmployeeExternalID != nil,
)
if err := tx.Create(user).Error; err != nil {
slog.Debug("registration-service: failed to create user",
"error", err.Error(),
"email", email,
)
return err
}
slog.Debug("registration-service: user created",
"email", email,
"userID", user.ID,
)
// Assign role based on registration type
var roleName string
if input.RequestType == model.RegistrationTypeParent {
roleName = "Parent"
} else {
roleName = "Employee"
}
slog.Debug("registration-service: assigning role",
"email", email,
"roleName", roleName,
"requestType", input.RequestType,
)
var role model.Role
if err := tx.Where("name = ?", roleName).First(&role).Error; err != nil {
slog.Debug("registration-service: role not found",
"error", err.Error(),
"roleName", roleName,
)
return err
}
if err := tx.Model(user).Association("Roles").Append(&role); err != nil {
slog.Debug("registration-service: failed to assign role",
"error", err.Error(),
"email", email,
"roleName", roleName,
)
return err
}
slog.Debug("registration-service: role assigned",
"email", email,
"roleID", role.ID,
"roleName", roleName,
)
// For employees with an ExternalID, check sync lead tables and assign lead roles
if input.RequestType == model.RegistrationTypeEmployee && input.EmployeeExternalID != nil {
if err := AssignLeadRolesForEmployee(tx, user.ID, *input.EmployeeExternalID); err != nil {
slog.Debug("registration-service: failed to assign lead roles (non-fatal)",
"error", err.Error(),
"email", email,
"externalID", *input.EmployeeExternalID,
)
// Non-fatal: leads will be assigned on next sync run
} else {
slog.Debug("registration-service: lead role assignment completed",
"email", email,
"externalID", *input.EmployeeExternalID,
)
}
}
}
// Store user info for email sending after transaction
userEmail = user.Email
firstName = user.FirstName
userLang = user.Language
if userLang == "" {
userLang = "de"
}
// Determine registration status based on invitation code
registrationStatus := model.RegistrationStatusPending
if input.InvitationChildID != nil || input.EmployeeInvitationSyncEmployeeID != nil {
registrationStatus = model.RegistrationStatusAutoApproved
}
slog.Debug("registration-service: registration status determined",
"email", email,
"status", registrationStatus,
)
// Create the registration request for admin review / audit trail
request = &model.RegistrationRequest{
FirstName: strings.TrimSpace(input.FirstName),
LastName: strings.TrimSpace(input.LastName),
Email: email,
RequestType: input.RequestType,
ChildFirstName: strings.TrimSpace(input.ChildFirstName),
ChildLastName: strings.TrimSpace(input.ChildLastName),
ChildBirthday: input.ChildBirthday,
LocationID: input.LocationID,
GroupID: input.GroupID,
RelationshipRole: input.RelationshipRole,
Status: registrationStatus,
ExistingUserID: existingUserID,
CreatedUserID: &user.ID, // Link to created user immediately
}
// If pre-matched via invitation code, skip matching and set directly
if input.InvitationChildID != nil {
request.ChildMatch = true
request.ChildMatchID = input.InvitationChildID
slog.Debug("registration-service: child pre-matched via invitation",
"email", email,
"childID", *input.InvitationChildID,
)
} else {
// Perform matching against Child table and intranet sync data
slog.Debug("registration-service: performing child matching",
"email", email,
"childFirstName", input.ChildFirstName,
"childLastName", input.ChildLastName,
)
s.performMatchingTx(tx, request)
slog.Debug("registration-service: child matching completed",
"email", email,
"childMatch", request.ChildMatch,
"childMatchID", request.ChildMatchID,
"parentSyncMatch", request.ParentSyncMatch,
"relationshipMatch", request.RelationshipMatch,
)
}
// Save the request
slog.Debug("registration-service: saving registration request",
"email", email,
)
if err := tx.Create(request).Error; err != nil {
slog.Debug("registration-service: failed to save registration request",
"error", err.Error(),
"email", email,
)
return err
}
slog.Debug("registration-service: registration request saved",
"email", email,
"requestID", request.ID,
)
// For auto-approved users (via invitation code), create UserChild relationship immediately
if input.InvitationChildID != nil && user != nil {
slog.Debug("registration-service: creating UserChild relationship",
"email", email,
"userID", user.ID,
"childID", *input.InvitationChildID,
"relationshipRole", input.RelationshipRole,
)
userChild := model.UserChild{
UserID: user.ID,
ChildID: *input.InvitationChildID,
RelationshipRole: input.RelationshipRole,
}
// Check if relationship already exists
var existing model.UserChild
if err := tx.Where("user_id = ? AND child_id = ?", user.ID, *input.InvitationChildID).
First(&existing).Error; err == gorm.ErrRecordNotFound {
if err := tx.Create(&userChild).Error; err != nil {
slog.Debug("registration-service: failed to create UserChild relationship",
"error", err.Error(),
"email", email,
)
return err
}
slog.Debug("registration-service: UserChild relationship created",
"email", email,
"userID", user.ID,
"childID", *input.InvitationChildID,
)
} else {
slog.Debug("registration-service: UserChild relationship already exists",
"email", email,
"userID", user.ID,
"childID", *input.InvitationChildID,
)
}
// Auto-link siblings from sync data
s.linkSiblings(tx, user.ID, *input.InvitationChildID, input.FirstName, input.LastName, input.RelationshipRole)
}
slog.Debug("registration-service: transaction completed successfully",
"email", email,
)
return nil
})
if err != nil {
slog.Debug("registration-service: transaction failed",
"error", err.Error(),
"email", email,
)
return nil, err
}
slog.Debug("registration-service: transaction succeeded, sending verification email",
"email", userEmail,
)
// Send verification email after successful transaction
// Email errors are logged but don't fail the registration
if emailErr := SendActivationEmail(userEmail, activationCode, firstName, userLang); emailErr != nil {
log.Printf("Failed to send verification email to %s: %v", userEmail, emailErr)
slog.Debug("registration-service: failed to send verification email",
"error", emailErr.Error(),
"email", userEmail,
)
} else {
slog.Debug("registration-service: verification email sent",
"email", userEmail,
)
}
slog.Debug("registration-service: CreateRegistrationRequest completed",
"email", userEmail,
"requestID", request.ID,
"status", request.Status,
)
return request, nil
}
// performMatching checks intranet data for matches and updates the request
func (s *RegistrationService) performMatching(request *model.RegistrationRequest) {
s.performMatchingWithDB(s.db, request)
}
// performMatchingTx checks intranet data for matches using a transaction
func (s *RegistrationService) performMatchingTx(tx *gorm.DB, request *model.RegistrationRequest) {
s.performMatchingWithDB(tx, request)
}
// performMatchingWithDB checks intranet data for matches using the provided DB
func (s *RegistrationService) performMatchingWithDB(db *gorm.DB, request *model.RegistrationRequest) {
// Only match for parent registrations with child info
if request.RequestType != model.RegistrationTypeParent || request.ChildFirstName == "" {
return
}
// 1. Match child in Child table by name + birthday + group
var child model.Child
childQuery := db.Where("LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)",
request.ChildFirstName, request.ChildLastName)
if request.ChildBirthday != nil {
childQuery = childQuery.Where("birthday = ?", request.ChildBirthday)
}
if request.GroupID != nil {
childQuery = childQuery.Where("group_id = ?", *request.GroupID)
}
if err := childQuery.First(&child).Error; err == nil {
request.ChildMatch = true
request.ChildMatchID = &child.ID
}
// 2. Match parent in SyncPerson by name (rolle 2=father, 3=mother, 4=other)
var syncPerson model.SyncPerson
if err := db.Where("LOWER(vorname) = LOWER(?) AND LOWER(nachname) = LOWER(?) AND rolle IN ?",
request.FirstName, request.LastName, model.SyncRolleParents).First(&syncPerson).Error; err == nil {
request.ParentSyncMatch = true
// 3. If we have both child and parent matches, check SyncParentChild
if request.ChildMatch && request.ChildMatchID != nil {
// Get the child's external ID
var matchedChild model.Child
if err := db.First(&matchedChild, *request.ChildMatchID).Error; err == nil && matchedChild.ExternalID != nil {
// Check SyncParentChild for the relationship
var syncParentChild model.SyncParentChild
if err := db.Where("eltern_id = ? AND kind_id = ?",
syncPerson.ExternalID, *matchedChild.ExternalID).First(&syncParentChild).Error; err == nil {
request.RelationshipMatch = true
}
}
}
}
}
// GetPendingRequestsForUser returns pending registration requests filtered by user's role
func (s *RegistrationService) GetPendingRequestsForUser(user *model.User) ([]model.RegistrationRequest, error) {
var requests []model.RegistrationRequest
query := s.db.Preload("Location").Preload("Group").
Where("status = ?", model.RegistrationStatusPending)
if user.IsAdmin() {
// Admin sees all pending requests
err := query.Order("created_at DESC").Find(&requests).Error
return requests, err
}
if user.IsHouseLeader() {
// LocationLead sees requests for their location(s)
locationIDs, err := user.GetLocationIDs(s.db)
if err != nil {
return nil, err
}
if len(locationIDs) > 0 {
query = query.Where("location_id IN ?", locationIDs)
}
// LocationLead can only approve parent registrations
query = query.Where("request_type = ?", model.RegistrationTypeParent)
err = query.Order("created_at DESC").Find(&requests).Error
return requests, err
}
if user.IsGroupLeader() {
// GroupLead sees only requests for their group(s)
var groupIDs []uint
err := s.db.Table("group_teachers").
Where("user_id = ?", user.ID).
Pluck("group_id", &groupIDs).Error
if err != nil {
return nil, err
}
if len(groupIDs) > 0 {
query = query.Where("group_id IN ?", groupIDs)
} else {
// No groups assigned - return empty
return []model.RegistrationRequest{}, nil
}
// GroupLead can only approve parent registrations
query = query.Where("request_type = ?", model.RegistrationTypeParent)
err = query.Order("created_at DESC").Find(&requests).Error
return requests, err
}
// Other roles don't have access
return []model.RegistrationRequest{}, nil
}
// GetRegistrationByID returns a registration request by ID with preloaded relations
func (s *RegistrationService) GetRegistrationByID(id uint) (*model.RegistrationRequest, error) {
var request model.RegistrationRequest
err := s.db.Preload("Location").Preload("Group").
Preload("ReviewedBy").Preload("ExistingUser").Preload("CreatedUser").
First(&request, id).Error
if err != nil {
return nil, err
}
return &request, nil
}
// CanUserAccessRegistration checks if a user has permission to view/act on a registration
func (s *RegistrationService) CanUserAccessRegistration(user *model.User, request *model.RegistrationRequest) bool {
if user.IsAdmin() {
return true
}
// Employee registrations require admin
if request.IsEmployeeRegistration() {
return false
}
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(s.db)
if err != nil {
return false
}
for _, id := range locationIDs {
if id == request.LocationID {
return true
}
}
}
if user.IsGroupLeader() {
var groupIDs []uint
s.db.Table("group_teachers").
Where("user_id = ?", user.ID).
Pluck("group_id", &groupIDs)
for _, id := range groupIDs {
if request.GroupID != nil && id == *request.GroupID {
return true
}
}
}
return false
}
// ApproveRegistration approves a registration and sets the user as approved.
// The user account was already created during registration - this just grants full access.
func (s *RegistrationService) ApproveRegistration(requestID uint, reviewerID uint) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
// Get the registration request
var request model.RegistrationRequest
if err := tx.First(&request, requestID).Error; err != nil {
return err
}
if request.Status != model.RegistrationStatusPending {
return errors.New("registration is not pending")
}
// User should already exist (created during registration)
if request.CreatedUserID == nil {
return errors.New("no user associated with this registration")
}
var user model.User
if err := tx.First(&user, *request.CreatedUserID).Error; err != nil {
return err
}
now := time.Now()
// Mark user as approved
user.Approved = true
user.ApprovedAt = &now
user.ApprovedByID = &reviewerID
if err := tx.Save(&user).Error; err != nil {
return err
}
// For parent registrations with a matched child, create UserChild relationship
if request.RequestType == model.RegistrationTypeParent && request.ChildMatchID != nil {
userChild := model.UserChild{
UserID: user.ID,
ChildID: *request.ChildMatchID,
RelationshipRole: request.RelationshipRole,
}
// Check if relationship already exists
var existing model.UserChild
if err := tx.Where("user_id = ? AND child_id = ?", user.ID, *request.ChildMatchID).
First(&existing).Error; err == gorm.ErrRecordNotFound {
if err := tx.Create(&userChild).Error; err != nil {
return err
}
}
// Auto-link siblings from sync data
s.linkSiblings(tx, user.ID, *request.ChildMatchID, request.FirstName, request.LastName, request.RelationshipRole)
}
// Update registration request
request.Status = model.RegistrationStatusApproved
request.ReviewedByID = &reviewerID
request.ReviewedAt = &now
if err := tx.Save(&request).Error; err != nil {
return err
}
return nil
})
return err
}
// RejectRegistration rejects a registration request with a reason
func (s *RegistrationService) RejectRegistration(requestID uint, reviewerID uint, reason string) error {
var request model.RegistrationRequest
if err := s.db.First(&request, requestID).Error; err != nil {
return err
}
if request.Status != model.RegistrationStatusPending {
return errors.New("registration is not pending")
}
now := time.Now()
request.Status = model.RegistrationStatusRejected
request.ReviewedByID = &reviewerID
request.ReviewedAt = &now
request.RejectReason = &reason
if err := s.db.Save(&request).Error; err != nil {
return err
}
// Send rejection notification email
// Email errors are logged but don't fail the rejection
if emailErr := SendRegistrationRejectionNotification(request.Email, request.FirstName, reason, "de"); emailErr != nil {
log.Printf("Failed to send rejection email to %s: %v", request.Email, emailErr)
}
return nil
}
// generateActivationCode generates a secure random activation code
func generateActivationCode() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// GetUserByActivationCode finds a user by their activation code
func (s *RegistrationService) GetUserByActivationCode(code string) (*model.User, error) {
var user model.User
if err := s.db.Where("activate_code = ?", code).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// ActivateUser activates a user account and sets their password
func (s *RegistrationService) ActivateUser(code string, password string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// Find user by activation code
var user model.User
if err := tx.Where("activate_code = ?", code).First(&user).Error; err != nil {
return errors.New("invalid activation code")
}
if user.Activated {
return errors.New("account already activated")
}
// Hash the password. bcrypt.DefaultCost (10) in production;
// MinCost (4) under `go test` to keep the registration test
// flow fast. Util.PasswordHashCost is the canonical helper but
// service can't import util (cycle).
hashCost := bcrypt.DefaultCost
if testing.Testing() {
hashCost = bcrypt.MinCost
}
passHash, err := bcrypt.GenerateFromPassword([]byte(password), hashCost)
if err != nil {
return err
}
// Create or update password record
var passwd model.Passwd
if err := tx.Where("user_id = ?", user.ID).First(&passwd).Error; err == gorm.ErrRecordNotFound {
passwd = model.Passwd{
UserId: user.ID,
PassHash: string(passHash),
}
if err := tx.Create(&passwd).Error; err != nil {
return err
}
} else {
passwd.PassHash = string(passHash)
if err := tx.Save(&passwd).Error; err != nil {
return err
}
}
// Activate the user
now := time.Now()
user.Activated = true
user.ActivatedAt = now
user.ActivateCode = nil // Clear the activation code
if err := tx.Save(&user).Error; err != nil {
return err
}
return nil
})
}
// GetAllLocations returns all locations for registration form dropdown
func (s *RegistrationService) GetAllLocations() ([]model.Location, error) {
var locations []model.Location
err := s.db.Order("name ASC").Find(&locations).Error
return locations, err
}
// GetGroupsByLocation returns groups for a specific location
func (s *RegistrationService) GetGroupsByLocation(locationID uint) ([]model.Group, error) {
var groups []model.Group
err := s.db.Where("location_id = ?", locationID).Order("name ASC").Find(&groups).Error
return groups, err
}
// linkSiblings auto-links all siblings of a known child to the user via SyncParentChild data.
// This is best-effort: failures are logged but do not fail the registration.
func (s *RegistrationService) linkSiblings(tx *gorm.DB, userID uint, knownChildID uint, firstName string, lastName string, role model.RelationshipRole) {
// 1. Get child's ExternalID
var child model.Child
if err := tx.First(&child, knownChildID).Error; err != nil || child.ExternalID == nil {
return
}
// 2. Find parent external IDs from SyncParentChild where this child appears
var parentChildRecords []model.SyncParentChild
if err := tx.Where("kind_id = ?", *child.ExternalID).Find(&parentChildRecords).Error; err != nil || len(parentChildRecords) == 0 {
return
}
// 3. Match the registering parent by name among the found parent external IDs
var matchedParentExternalID string
for _, pc := range parentChildRecords {
var syncPerson model.SyncPerson
if err := tx.Where("external_id = ? AND LOWER(vorname) = LOWER(?) AND LOWER(nachname) = LOWER(?) AND rolle IN ?",
pc.ElternID, firstName, lastName, model.SyncRolleParents).First(&syncPerson).Error; err == nil {
matchedParentExternalID = syncPerson.ExternalID
break
}
}
if matchedParentExternalID == "" {
return
}
// 4. Get ALL children for this parent
var allSiblingRecords []model.SyncParentChild
if err := tx.Where("eltern_id = ?", matchedParentExternalID).Find(&allSiblingRecords).Error; err != nil {
return
}
// 5. Create UserChild for each sibling (skip the already-linked child and duplicates)
for _, sr := range allSiblingRecords {
var siblingChild model.Child
if err := tx.Where("external_id = ?", sr.KindID).First(&siblingChild).Error; err != nil {
continue
}
if siblingChild.ID == knownChildID {
continue
}
var existing model.UserChild
if err := tx.Where("user_id = ? AND child_id = ?", userID, siblingChild.ID).First(&existing).Error; err == gorm.ErrRecordNotFound {
uc := model.UserChild{UserID: userID, ChildID: siblingChild.ID, RelationshipRole: role}
if err := tx.Create(&uc).Error; err != nil {
slog.Debug("registration-service: failed to link sibling", "error", err.Error(), "userID", userID, "childID", siblingChild.ID)
continue
}
slog.Debug("registration-service: sibling linked", "userID", userID, "childID", siblingChild.ID)
}
}
}
// GetAllRequestsFiltered returns all registration requests with optional status filter
func (s *RegistrationService) GetAllRequestsFiltered(status *model.RegistrationStatus) ([]model.RegistrationRequest, error) {
var requests []model.RegistrationRequest
query := s.db.Preload("Location").Preload("Group").Preload("ReviewedBy").Preload("CreatedUser")
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Order("created_at DESC").Find(&requests).Error
return requests, err
}
package service
import (
"context"
"log/slog"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// StartReminderScheduler starts a background goroutine that runs daily at 07:00
// to create reminder news posts for calendar events happening in 2 days.
func StartReminderScheduler(ctx context.Context, db *gorm.DB) {
log := logger.WithComponent("reminder-scheduler")
log.Info("reminder scheduler started")
go func() {
for {
next := calculateNext0700()
delay := time.Until(next)
log.Info("next reminder check scheduled", "at", next.Format("2006-01-02 15:04:05"), "in", delay.String())
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
log.Info("reminder scheduler stopped")
return
case <-timer.C:
processEventReminders(db, log)
}
}
}()
}
// calculateNext0700 returns the next 07:00 in local time.
func calculateNext0700() time.Time {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 7, 0, 0, 0, now.Location())
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next
}
// processEventReminders finds events starting in 2 days that have send_reminder=true
// and reminder_news_id IS NULL, creates reminder news posts, and sends email notifications.
func processEventReminders(db *gorm.DB, log *slog.Logger) {
log.Info("processing event reminders")
// Target date: 2 days from now
targetDate := time.Now().AddDate(0, 0, 2)
startOfDay := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, targetDate.Location())
endOfDay := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 23, 59, 59, 999999999, targetDate.Location())
var events []model.CalendarEvent
err := db.Where("send_reminder = ?", true).
Where("reminder_news_id IS NULL").
Where("cancelled = ?", false).
Where("deleted_at IS NULL").
Where("start_date >= ? AND start_date <= ?", startOfDay, endOfDay).
Find(&events).Error
if err != nil {
log.Error("failed to query events for reminders", "error", err)
return
}
if len(events) == 0 {
log.Info("no events need reminders today")
return
}
log.Info("found events needing reminders", "count", len(events))
for _, event := range events {
news, err := GenerateReminderNewsForEvent(db, &event, "de")
if err != nil {
log.Error("failed to generate reminder news", "eventId", event.ID, "error", err)
continue
}
log.Info("created reminder news", "newsId", news.ID, "eventId", event.ID, "eventTitle", event.Title)
// Send email notifications for the reminder news
NotifyNewsPublished(db, news)
}
}
package service
import (
"context"
"log/slog"
"time"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// StartSyncScheduler starts a background goroutine that runs ProcessAll at midnight
// if data was received that day and auto-sync is enabled.
func StartSyncScheduler(ctx context.Context, db *gorm.DB) {
log := logger.WithComponent("scheduler")
log.Info("sync scheduler started")
go func() {
for {
nextMidnight := calculateNextMidnight()
delay := time.Until(nextMidnight)
log.Info("next sync check scheduled", "at", nextMidnight.Format("2006-01-02 15:04:05"), "in", delay.String())
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
log.Info("sync scheduler stopped")
return
case <-timer.C:
runMidnightSync(db, log)
}
}
}()
}
// calculateNextMidnight returns the next midnight in local time
func calculateNextMidnight() time.Time {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
return next
}
// runMidnightSync checks if auto-sync is enabled and if data was received the previous day,
// then runs ProcessAll if both conditions are met.
func runMidnightSync(db *gorm.DB, log *slog.Logger) {
// Check if auto-sync is enabled
settings, err := model.GetSyncSettings()
if err != nil {
log.Error("failed to get sync settings", "error", err)
return
}
if !settings.AutoSyncEnabled {
log.Info("auto-sync is disabled, skipping midnight sync")
return
}
// Check if data was received the previous day
yesterday := time.Now().AddDate(0, 0, -1)
startOfDay := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
endOfDay := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 999999999, yesterday.Location())
var count int64
if err := db.Model(&model.SyncReceiveLog{}).
Where("received_at >= ? AND received_at <= ?", startOfDay, endOfDay).
Count(&count).Error; err != nil {
log.Error("failed to count receive logs", "error", err)
return
}
if count == 0 {
log.Info("no data received yesterday, skipping midnight sync")
return
}
log.Info("data received yesterday, starting auto-sync", "receiveLogCount", count)
// Run ProcessAll with system user ID 1
processor := NewSyncProcessor(db, 1)
result, err := processor.ProcessAll()
if err != nil {
log.Error("auto-sync ProcessAll failed", "error", err)
return
}
log.Info("auto-sync ProcessAll completed",
"dataType", result.DataType,
"total", result.RecordsTotal,
"created", result.Created,
"updated", result.Updated,
"skipped", result.Skipped,
"errored", result.Errored,
)
}
package service
// Sync processor service - processes staged intranet data into app tables
// Transforms SyncLocation/SyncGroup/SyncPerson into Location/Group/Child
//
// [impl->dsn~import-export-design~1]
import (
"fmt"
"log"
"strings"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// ProcessingResult contains the results of a processing operation
type ProcessingResult struct {
DataType string
RecordsTotal int
Created int
Updated int
Skipped int
Errored int
Errors []string
StartedAt time.Time
CompletedAt time.Time
}
// SyncProcessor handles processing of staged intranet data
type SyncProcessor struct {
db *gorm.DB
triggeredBy uint
}
// NewSyncProcessor creates a new sync processor
func NewSyncProcessor(db *gorm.DB, triggeredByUserID uint) *SyncProcessor {
return &SyncProcessor{
db: db,
triggeredBy: triggeredByUserID,
}
}
// ProcessLocations processes SyncLocation records into Location records
func (p *SyncProcessor) ProcessLocations() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessLocations: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "location",
StartedAt: time.Now(),
}
// Create log entry
dbLog := p.createLogEntry("location")
var syncLocations []model.SyncLocation
if err := p.db.Find(&syncLocations).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocations: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncLocations)
log.Printf("[SYNC-PROCESSOR] ProcessLocations: Found %d records to process", len(syncLocations))
for _, sl := range syncLocations {
var location model.Location
err := p.db.Where("external_id = ?", sl.EinrichtungsID).First(&location).Error
if err == gorm.ErrRecordNotFound {
// Create new location
newLocation := model.Location{
ExternalID: &sl.EinrichtungsID,
Name: sl.Name,
Address: sl.Adresse,
}
if err := p.db.Create(&newLocation).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocations: FAILED to create ExternalID=%s: %v", sl.EinrichtungsID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to create location %s: %v", sl.EinrichtungsID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocations: Created ExternalID=%s Name=%s ID=%d", sl.EinrichtungsID, sl.Name, newLocation.ID)
result.Created++
} else if err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocations: FAILED to query ExternalID=%s: %v", sl.EinrichtungsID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query location %s: %v", sl.EinrichtungsID, err))
continue
} else {
// Update existing location
updates := map[string]interface{}{
"name": sl.Name,
"address": sl.Adresse,
}
if err := p.db.Model(&location).Updates(updates).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocations: FAILED to update ExternalID=%s: %v", sl.EinrichtungsID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to update location %s: %v", sl.EinrichtungsID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocations: Updated ExternalID=%s Name=%s ID=%d", sl.EinrichtungsID, sl.Name, location.ID)
result.Updated++
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessLocations: COMPLETE - Created=%d Updated=%d Errored=%d Duration=%v",
result.Created, result.Updated, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// ProcessGroups processes SyncGroup records into Group records
func (p *SyncProcessor) ProcessGroups() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "group",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("group")
var syncGroups []model.SyncGroup
if err := p.db.Find(&syncGroups).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncGroups)
log.Printf("[SYNC-PROCESSOR] ProcessGroups: Found %d records to process", len(syncGroups))
for _, sg := range syncGroups {
// Find the location by ExternalID
var location model.Location
if err := p.db.Where("external_id = ?", sg.EinrichtungsID).First(&location).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: SKIPPED GruppenID=%s - location %s not found", sg.GruppenID, sg.EinrichtungsID)
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("Skipped group %s: location %s not found", sg.GruppenID, sg.EinrichtungsID))
} else {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: FAILED to query location for GruppenID=%s: %v", sg.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query location for group %s: %v", sg.GruppenID, err))
}
continue
}
var group model.Group
err := p.db.Where("external_id = ?", sg.GruppenID).First(&group).Error
if err == gorm.ErrRecordNotFound {
// Create new group
newGroup := model.Group{
ExternalID: &sg.GruppenID,
Name: sg.Name,
LocationId: location.ID,
}
if err := p.db.Create(&newGroup).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: FAILED to create GruppenID=%s: %v", sg.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to create group %s: %v", sg.GruppenID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessGroups: Created GruppenID=%s Name=%s LocationID=%d ID=%d", sg.GruppenID, sg.Name, location.ID, newGroup.ID)
result.Created++
} else if err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: FAILED to query GruppenID=%s: %v", sg.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query group %s: %v", sg.GruppenID, err))
continue
} else {
// Update existing group
updates := map[string]interface{}{
"name": sg.Name,
"location_id": location.ID,
}
if err := p.db.Model(&group).Updates(updates).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroups: FAILED to update GruppenID=%s: %v", sg.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to update group %s: %v", sg.GruppenID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessGroups: Updated GruppenID=%s Name=%s LocationID=%d ID=%d", sg.GruppenID, sg.Name, location.ID, group.ID)
result.Updated++
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessGroups: COMPLETE - Created=%d Updated=%d Skipped=%d Errored=%d Duration=%v",
result.Created, result.Updated, result.Skipped, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// ProcessChildren processes SyncPerson records (rolle=1 for child) into Child records
func (p *SyncProcessor) ProcessChildren() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessChildren: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "child",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("child")
var syncPersons []model.SyncPerson
if err := p.db.Where("rolle = ?", model.SyncRolleChild).Find(&syncPersons).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildren: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncPersons)
log.Printf("[SYNC-PROCESSOR] ProcessChildren: Found %d records to process", len(syncPersons))
for _, sp := range syncPersons {
var child model.Child
err := p.db.Where("external_id = ?", sp.ExternalID).First(&child).Error
if err == gorm.ErrRecordNotFound {
// Create new child
newChild := model.Child{
ExternalID: &sp.ExternalID,
FirstName: sp.Vorname,
LastName: sp.Nachname,
Active: true,
}
if sp.Geburtstag != nil {
newChild.Birthday = *sp.Geburtstag
}
if err := p.db.Create(&newChild).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildren: FAILED to create ExternalID=%s: %v", sp.ExternalID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to create child %s: %v", sp.ExternalID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessChildren: Created ExternalID=%s Name=%s %s ID=%d", sp.ExternalID, sp.Vorname, sp.Nachname, newChild.ID)
result.Created++
} else if err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildren: FAILED to query ExternalID=%s: %v", sp.ExternalID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query child %s: %v", sp.ExternalID, err))
continue
} else {
// Update existing child
updates := map[string]interface{}{
"first_name": sp.Vorname,
"last_name": sp.Nachname,
}
if sp.Geburtstag != nil {
updates["birthday"] = *sp.Geburtstag
}
if err := p.db.Model(&child).Updates(updates).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildren: FAILED to update ExternalID=%s: %v", sp.ExternalID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to update child %s: %v", sp.ExternalID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessChildren: Updated ExternalID=%s Name=%s %s ID=%d", sp.ExternalID, sp.Vorname, sp.Nachname, child.ID)
result.Updated++
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessChildren: COMPLETE - Created=%d Updated=%d Errored=%d Duration=%v",
result.Created, result.Updated, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// ProcessChildGroups processes SyncBelegung records to create/update Enrollment records
// and update the cached Child.GroupId field based on current active enrollments.
// This processes both planning (status=2) and active (status=3) belegungen.
func (p *SyncProcessor) ProcessChildGroups() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "child_group",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("child_group")
// Get all belegungen (both planning=2 and active=3)
var syncBelegungen []model.SyncBelegung
if err := p.db.Where("status IN ?", []int{EnrollmentStatusPlanning, EnrollmentStatusActive}).Find(&syncBelegungen).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncBelegungen)
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: Found %d belegungen (status=2 or 3) to process", len(syncBelegungen))
// Track affected children to update their cached GroupId at the end
affectedChildIDs := make(map[uint]bool)
for _, sb := range syncBelegungen {
// Find the child by ExternalID
var child model.Child
if err := p.db.Where("external_id = ?", sb.KindID).First(&child).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: SKIPPED - child %s not found", sb.KindID)
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("Skipped belegung: child %s not found", sb.KindID))
} else {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: FAILED to query child %s: %v", sb.KindID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query child %s: %v", sb.KindID, err))
}
continue
}
// Find the group by ExternalID
var group model.Group
if err := p.db.Where("external_id = ?", sb.GruppenID).First(&group).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: SKIPPED - group %s not found", sb.GruppenID)
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("Skipped belegung: group %s not found", sb.GruppenID))
} else {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: FAILED to query group %s: %v", sb.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query group %s: %v", sb.GruppenID, err))
}
continue
}
// Create or update enrollment record
syncData := SyncEnrollmentData{
ValidFrom: sb.Von,
ValidUntil: sb.Bis,
Status: sb.Status,
CareDaysBinary: sb.TageBinaer,
CareDaysCount: sb.AnzahlTage,
ExternalIDKrp: sb.IDKrp,
ExternalIDUe3: sb.IDUe3,
Comments: sb.Comments,
}
enrollment, err := CreateOrUpdateEnrollmentFromSync(p.db, child.ID, group.ID, syncData)
if err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: FAILED to create/update enrollment for child %s group %s: %v", sb.KindID, sb.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to create/update enrollment: %v", err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: Created/Updated EnrollmentID=%d for ChildID=%d GroupID=%d Status=%d", enrollment.ID, child.ID, group.ID, sb.Status)
result.Updated++
affectedChildIDs[child.ID] = true
}
// Update cached GroupId for all affected children based on their current enrollment
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: Updating cached GroupId for %d affected children", len(affectedChildIDs))
for childID := range affectedChildIDs {
if err := UpdateChildGroupFromEnrollments(p.db, childID); err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: FAILED to update cached GroupId for child %d: %v", childID, err)
// Don't count as error - enrollment was created successfully
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessChildGroups: COMPLETE - Updated=%d Skipped=%d Errored=%d Duration=%v",
result.Updated, result.Skipped, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// isDateRangeCurrentlyValid checks if a date range (von/bis) is currently valid.
// NULL values are treated as unbounded (always valid on that side).
func isDateRangeCurrentlyValid(von *time.Time, bis *time.Time, now time.Time) bool {
if von != nil && von.After(now) {
return false // Not yet valid
}
if bis != nil && bis.Before(now) {
return false // Expired
}
return true
}
// ProcessGroupLeads processes SyncGroupLead records to assign GroupLead roles,
// set Group.LeadId, and add leads to group_teachers.
func (p *SyncProcessor) ProcessGroupLeads() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "group_lead",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("group_lead")
var syncGroupLeads []model.SyncGroupLead
if err := p.db.Find(&syncGroupLeads).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncGroupLeads)
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: Found %d records to process", len(syncGroupLeads))
now := time.Now()
for _, sgl := range syncGroupLeads {
// Check if date range is currently valid
if !isDateRangeCurrentlyValid(sgl.GVon, sgl.GBis, now) {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: SKIPPED GruppenID=%s MitarbeiterID=%s - date range not valid", sgl.GruppenID, sgl.MitarbeiterID)
result.Skipped++
continue
}
// Find the user by ExternalID
var user model.User
if err := p.db.Where("external_id = ?", sgl.MitarbeiterID).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: SKIPPED GruppenID=%s - user %s not registered", sgl.GruppenID, sgl.MitarbeiterID)
result.Skipped++
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED to query user %s: %v", sgl.MitarbeiterID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query user %s: %v", sgl.MitarbeiterID, err))
continue
}
// Find the group by ExternalID
var group model.Group
if err := p.db.Where("external_id = ?", sgl.GruppenID).First(&group).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: SKIPPED - group %s not found", sgl.GruppenID)
result.Skipped++
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED to query group %s: %v", sgl.GruppenID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query group %s: %v", sgl.GruppenID, err))
continue
}
// Assign GroupLead role
if err := EnsureUserHasRole(p.db, user.ID, "GroupLead"); err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED to assign GroupLead role to user %d: %v", user.ID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to assign GroupLead role to user %d: %v", user.ID, err))
continue
}
// Set Group.LeadId
if err := p.db.Model(&group).Update("lead_id", user.ID).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED to set LeadId on group %d: %v", group.ID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to set LeadId on group %d: %v", group.ID, err))
continue
}
// Add to group_teachers for this specific group
if err := EnsureUserInGroup(p.db, user.ID, group.ID); err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: FAILED to add user %d to group_teachers for group %d: %v", user.ID, group.ID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to add user %d to group_teachers: %v", user.ID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: Assigned UserID=%d as GroupLead for GroupID=%d (ExternalGroup=%s)", user.ID, group.ID, sgl.GruppenID)
result.Updated++
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessGroupLeads: COMPLETE - Updated=%d Skipped=%d Errored=%d Duration=%v",
result.Updated, result.Skipped, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// ProcessLocationLeads processes SyncLocationLead records to assign LocationLead roles,
// set Location.LeadId and Location.Lead2ndId.
func (p *SyncProcessor) ProcessLocationLeads() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "location_lead",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("location_lead")
var syncLocationLeads []model.SyncLocationLead
if err := p.db.Find(&syncLocationLeads).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED - DB error loading sync records: %v", err)
p.completeLogEntry(dbLog, result, err)
return result, err
}
result.RecordsTotal = len(syncLocationLeads)
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: Found %d records to process", len(syncLocationLeads))
now := time.Now()
for _, sll := range syncLocationLeads {
// Check if date range is currently valid
if !isDateRangeCurrentlyValid(sll.GVon, sll.GBis, now) {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: SKIPPED EinrichtungsID=%s MitarbeiterID=%s - date range not valid", sll.EinrichtungsID, sll.MitarbeiterID)
result.Skipped++
continue
}
// Find the location by ExternalID
var location model.Location
if err := p.db.Where("external_id = ?", sll.EinrichtungsID).First(&location).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: SKIPPED - location %s not found", sll.EinrichtungsID)
result.Skipped++
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to query location %s: %v", sll.EinrichtungsID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query location %s: %v", sll.EinrichtungsID, err))
continue
}
// Process primary lead (MitarbeiterID)
var primaryUser model.User
if err := p.db.Where("external_id = ?", sll.MitarbeiterID).First(&primaryUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: SKIPPED EinrichtungsID=%s - primary lead user %s not registered", sll.EinrichtungsID, sll.MitarbeiterID)
result.Skipped++
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to query user %s: %v", sll.MitarbeiterID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query user %s: %v", sll.MitarbeiterID, err))
continue
}
// Assign LocationLead role to primary
if err := EnsureUserHasRole(p.db, primaryUser.ID, "LocationLead"); err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to assign LocationLead role to user %d: %v", primaryUser.ID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to assign LocationLead role to user %d: %v", primaryUser.ID, err))
continue
}
// Set Location.LeadId
if err := p.db.Model(&location).Update("lead_id", primaryUser.ID).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to set LeadId on location %d: %v", location.ID, err)
result.Errored++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to set LeadId on location %d: %v", location.ID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: Assigned UserID=%d as LocationLead for LocationID=%d (ExternalLocation=%s)", primaryUser.ID, location.ID, sll.EinrichtungsID)
result.Updated++
// Process deputy (StellvertreterID) if set
if sll.StellvertreterID != nil && *sll.StellvertreterID != "" {
var deputyUser model.User
if err := p.db.Where("external_id = ?", *sll.StellvertreterID).First(&deputyUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: Deputy user %s not registered for location %s, skipping deputy", *sll.StellvertreterID, sll.EinrichtungsID)
// Don't count as skipped - the primary lead was still processed
} else {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to query deputy user %s: %v", *sll.StellvertreterID, err)
result.Errors = append(result.Errors, fmt.Sprintf("Failed to query deputy user %s: %v", *sll.StellvertreterID, err))
}
continue
}
// Assign LocationLead role to deputy
if err := EnsureUserHasRole(p.db, deputyUser.ID, "LocationLead"); err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to assign LocationLead role to deputy %d: %v", deputyUser.ID, err)
result.Errors = append(result.Errors, fmt.Sprintf("Failed to assign LocationLead role to deputy %d: %v", deputyUser.ID, err))
continue
}
// Set Location.Lead2ndId
if err := p.db.Model(&location).Update("lead2nd_id", deputyUser.ID).Error; err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: FAILED to set Lead2ndId on location %d: %v", location.ID, err)
result.Errors = append(result.Errors, fmt.Sprintf("Failed to set Lead2ndId on location %d: %v", location.ID, err))
continue
}
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: Assigned deputy UserID=%d as Lead2nd for LocationID=%d", deputyUser.ID, location.ID)
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessLocationLeads: COMPLETE - Updated=%d Skipped=%d Errored=%d Duration=%v",
result.Updated, result.Skipped, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// ProcessAll runs all processing functions in the correct order
func (p *SyncProcessor) ProcessAll() (*ProcessingResult, error) {
log.Printf("[SYNC-PROCESSOR] ProcessAll: START - TriggeredBy=%d", p.triggeredBy)
result := &ProcessingResult{
DataType: "all",
StartedAt: time.Now(),
}
dbLog := p.createLogEntry("all")
// Process in dependency order
processors := []struct {
name string
fn func() (*ProcessingResult, error)
}{
{"locations", p.ProcessLocations},
{"groups", p.ProcessGroups},
{"children", p.ProcessChildren},
{"child_groups", p.ProcessChildGroups},
{"group_leads", p.ProcessGroupLeads},
{"location_leads", p.ProcessLocationLeads},
}
for _, proc := range processors {
log.Printf("[SYNC-PROCESSOR] ProcessAll: Starting %s processing...", proc.name)
subResult, err := proc.fn()
if err != nil {
log.Printf("[SYNC-PROCESSOR] ProcessAll: %s processing FAILED: %v", proc.name, err)
result.Errors = append(result.Errors, fmt.Sprintf("%s processing failed: %v", proc.name, err))
} else {
log.Printf("[SYNC-PROCESSOR] ProcessAll: %s processing completed - Total=%d Created=%d Updated=%d Skipped=%d Errored=%d",
proc.name, subResult.RecordsTotal, subResult.Created, subResult.Updated, subResult.Skipped, subResult.Errored)
}
if subResult != nil {
result.RecordsTotal += subResult.RecordsTotal
result.Created += subResult.Created
result.Updated += subResult.Updated
result.Skipped += subResult.Skipped
result.Errored += subResult.Errored
result.Errors = append(result.Errors, subResult.Errors...)
}
}
result.CompletedAt = time.Now()
duration := result.CompletedAt.Sub(result.StartedAt)
log.Printf("[SYNC-PROCESSOR] ProcessAll: COMPLETE - TotalRecords=%d Created=%d Updated=%d Skipped=%d Errored=%d Duration=%v",
result.RecordsTotal, result.Created, result.Updated, result.Skipped, result.Errored, duration)
p.completeLogEntry(dbLog, result, nil)
return result, nil
}
// GetPendingCounts returns counts of pending records in each staging table
func (p *SyncProcessor) GetPendingCounts() (map[string]int64, error) {
counts := make(map[string]int64)
var count int64
// Locations
if err := p.db.Model(&model.SyncLocation{}).Count(&count).Error; err != nil {
return nil, err
}
counts["locations"] = count
// Groups
if err := p.db.Model(&model.SyncGroup{}).Count(&count).Error; err != nil {
return nil, err
}
counts["groups"] = count
// Children (SyncPerson with rolle=1 for child)
if err := p.db.Model(&model.SyncPerson{}).Where("rolle = ?", model.SyncRolleChild).Count(&count).Error; err != nil {
return nil, err
}
counts["children"] = count
// Belegungen (active contracts)
if err := p.db.Model(&model.SyncBelegung{}).Where("status = ?", 3).Count(&count).Error; err != nil {
return nil, err
}
counts["child_groups"] = count
// Group leads (currently valid)
now := time.Now()
if err := p.db.Model(&model.SyncGroupLead{}).
Where("g_von IS NULL OR g_von <= ?", now).
Where("g_bis IS NULL OR g_bis >= ?", now).
Count(&count).Error; err != nil {
return nil, err
}
counts["group_leads"] = count
// Location leads (currently valid)
if err := p.db.Model(&model.SyncLocationLead{}).
Where("g_von IS NULL OR g_von <= ?", now).
Where("g_bis IS NULL OR g_bis >= ?", now).
Count(&count).Error; err != nil {
return nil, err
}
counts["location_leads"] = count
return counts, nil
}
// GetRecentLogs returns the most recent processing logs
func (p *SyncProcessor) GetRecentLogs(limit int) ([]model.SyncProcessingLog, error) {
var logs []model.SyncProcessingLog
err := p.db.Preload("TriggeredBy").Order("started_at DESC").Limit(limit).Find(&logs).Error
return logs, err
}
// GetRecentReceiveLogs returns the most recent receive logs
func (p *SyncProcessor) GetRecentReceiveLogs(limit int) ([]model.SyncReceiveLog, error) {
var logs []model.SyncReceiveLog
err := p.db.Order("received_at DESC").Limit(limit).Find(&logs).Error
return logs, err
}
// createLogEntry creates a new processing log entry with status "running"
func (p *SyncProcessor) createLogEntry(dataType string) *model.SyncProcessingLog {
log := &model.SyncProcessingLog{
DataType: dataType,
StartedAt: time.Now(),
Status: "running",
TriggeredByID: p.triggeredBy,
}
p.db.Create(log)
return log
}
// completeLogEntry updates the log entry with final results
func (p *SyncProcessor) completeLogEntry(log *model.SyncProcessingLog, result *ProcessingResult, err error) {
now := time.Now()
log.CompletedAt = &now
log.RecordsTotal = result.RecordsTotal
log.Created = result.Created
log.Updated = result.Updated
log.Skipped = result.Skipped
log.Errored = result.Errored
if err != nil {
log.Status = "failed"
log.ErrorDetails = err.Error()
} else if result.Errored > 0 {
log.Status = "completed"
log.ErrorDetails = strings.Join(result.Errors, "\n")
} else {
log.Status = "completed"
}
p.db.Save(log)
}
package service
import (
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// GetUnreadNewsCount returns the count of unread news items for a user
func GetUnreadNewsCount(db *gorm.DB, userID uint) int {
var count int64
// Get user to check role
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return 0
}
if user.IsParent() {
// For parents: get news for their children's groups/locations
// Get parent's children
var children []model.Child
db.Joins("JOIN user_children ON user_children.child_id = children.id").
Where("user_children.user_id = ?", userID).
Preload("Group").
Find(&children)
// Collect group IDs and location IDs
groupIDsMap := make(map[uint]bool)
locationIDsMap := make(map[uint]bool)
for _, child := range children {
if child.Group != nil && child.GroupId != nil {
groupIDsMap[*child.GroupId] = true
locationIDsMap[child.Group.LocationId] = true
}
}
var groupIDs []uint
for id := range groupIDsMap {
groupIDs = append(groupIDs, id)
}
var locationIDs []uint
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
if len(groupIDs) == 0 {
return 0
}
// Count news items that are unread. Employee-only entries are
// excluded — parents are not supposed to see them anywhere, so
// the badge must not light up for them either.
//
// The NOT EXISTS subquery filters `news_reads.deleted_at IS NULL`
// because we're hitting the raw table without GORM's automatic
// soft-delete scope — otherwise a soft-deleted read record keeps
// the news marked "read" forever from the badge's perspective.
db.Table("news").
Select("COUNT(DISTINCT news.id)").
Where("news.deleted_at IS NULL").
Where("news.employee_only = ?", false).
Where("(news.group_id IN ? OR (news.group_id IS NULL AND news.location_id IN ?))", groupIDs, locationIDs).
Where("NOT EXISTS (SELECT 1 FROM news_reads WHERE news_reads.news_id = news.id AND news_reads.user_id = ? AND news_reads.read_at IS NOT NULL AND news_reads.deleted_at IS NULL)", userID).
Count(&count)
} else if user.IsEmployee() {
// For employees: get news for their assigned locations
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return 0
}
db.Table("news").
Select("COUNT(DISTINCT news.id)").
Where("news.deleted_at IS NULL").
Where("news.location_id IN ?", locationIDs).
Where("NOT EXISTS (SELECT 1 FROM news_reads WHERE news_reads.news_id = news.id AND news_reads.user_id = ? AND news_reads.read_at IS NOT NULL AND news_reads.deleted_at IS NULL)", userID).
Count(&count)
}
return int(count)
}
// GetUnreadMessagesCount returns the count of unread messages for a user (parents only)
func GetUnreadMessagesCount(db *gorm.DB, userID uint) int {
var count int64
// Count messages that don't have a read record. The NOT EXISTS
// subquery filters `message_reads.deleted_at IS NULL` so a
// soft-deleted read row doesn't keep the message marked "read"
// forever — the raw-table form bypasses GORM's automatic
// soft-delete scope.
db.Table("messages").
Select("COUNT(DISTINCT messages.id)").
Joins("JOIN message_recipients ON message_recipients.message_id = messages.id").
Where("message_recipients.user_id = ?", userID).
Where("messages.draft = ?", false).
Where("messages.deleted_at IS NULL").
Where("NOT EXISTS (SELECT 1 FROM message_reads WHERE message_reads.message_id = messages.id AND message_reads.user_id = ? AND message_reads.deleted_at IS NULL)", userID).
Count(&count)
return int(count)
}
// GetUnreadLettersCount returns the count of unread parental letters for a user (parents only)
func GetUnreadLettersCount(db *gorm.DB, userID uint) int {
var count int64
// Get user's children to find relevant letters
var children []model.Child
db.Joins("JOIN user_children ON user_children.child_id = children.id").
Where("user_children.user_id = ?", userID).
Preload("Group").
Find(&children)
// Collect group IDs and location IDs
groupIDsMap := make(map[uint]bool)
locationIDsMap := make(map[uint]bool)
for _, child := range children {
if child.Group != nil && child.GroupId != nil {
groupIDsMap[*child.GroupId] = true
locationIDsMap[child.Group.LocationId] = true
}
}
var groupIDs []uint
for id := range groupIDsMap {
groupIDs = append(groupIDs, id)
}
var locationIDs []uint
for id := range locationIDsMap {
locationIDs = append(locationIDs, id)
}
if len(groupIDs) == 0 {
return 0
}
// Get all published letters for these groups/locations
var allLetters []model.ParentalLetter
db.Where("deleted_at IS NULL").
Where("draft = ?", false).
Where("review_status = ?", "published").
Where("(group_id IN ? OR (group_id IS NULL AND location_id IN ?))", groupIDs, locationIDs).
Find(&allLetters)
// Count unread letters using the same logic as the list page
unreadCount := 0
for _, letter := range allLetters {
var readRecord model.ParentalLetterRead
err := db.Where("letter_id = ? AND user_id = ?", letter.ID, userID).First(&readRecord).Error
// Determine if letter is considered "read/completed"
isRead := false
if err == nil && readRecord.ReadAt != nil {
if letter.InteractionType == "answer_required" {
// Must have an answer to be considered complete
isRead = readRecord.Answer != nil && *readRecord.Answer != ""
} else {
// For informal and answer_possible, just viewing is enough
isRead = true
}
}
if !isRead {
unreadCount++
}
}
count = int64(unreadCount)
return int(count)
}
// GetUnreadLettersCountForEmployee returns the count of unread published parental letters
// for an employee, scoped by their assigned locations.
func GetUnreadLettersCountForEmployee(db *gorm.DB, userID uint) int {
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return 0
}
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return 0
}
// Get all group IDs in those locations
var groupIDs []uint
db.Model(&model.Group{}).
Where("location_id IN ?", locationIDs).
Pluck("id", &groupIDs)
if len(groupIDs) == 0 {
return 0
}
var count int64
db.Table("parental_letters").
Where("parental_letters.deleted_at IS NULL").
Where("parental_letters.draft = ?", false).
Where("parental_letters.review_status = ?", "published").
Where("(parental_letters.group_id IN ? OR (parental_letters.group_id IS NULL AND parental_letters.location_id IN ?))", groupIDs, locationIDs).
Where("NOT EXISTS (SELECT 1 FROM parental_letter_reads WHERE parental_letter_reads.letter_id = parental_letters.id AND parental_letter_reads.user_id = ? AND parental_letter_reads.read_at IS NOT NULL)", userID).
Count(&count)
return int(count)
}
// GetPendingEmployeeActionsCount returns the count of parental letters requiring action from an employee
// This includes:
// - Letters delegated to the employee (needing their work)
// - Letters pending the employee's review (needing approval/rejection)
func GetPendingEmployeeActionsCount(db *gorm.DB, userID uint) int {
var delegatedCount int64
var reviewCount int64
// Count letters delegated to this employee that are still in draft status
db.Table("parental_letters").
Where("deleted_at IS NULL").
Where("delegated_to_id = ?", userID).
Where("review_status = ?", "draft").
Count(&delegatedCount)
// Count letters pending this employee's review
db.Table("parental_letters").
Where("deleted_at IS NULL").
Where("reviewer_id = ?", userID).
Where("review_status = ?", "pending_review").
Count(&reviewCount)
return int(delegatedCount + reviewCount)
}
// GetUnreadChatMessagesCount returns the count of unread chat messages for an employee
func GetUnreadChatMessagesCount(db *gorm.DB, userID uint) int {
var count int64
// Get user to check role
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return 0
}
if !user.IsEmployee() {
return 0
}
// Get user's location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return 0
}
// Get all chats where user is a participant (or all location chats if LocationLead)
var chatIDs []uint
if user.IsHouseLeader() {
// Location leads can see all chats at their locations
db.Model(&model.EmployeeChat{}).
Where("location_id IN ?", locationIDs).
Pluck("id", &chatIDs)
} else {
// Regular employees see only chats where they are participants
db.Table("employee_chat_participants").
Joins("JOIN employee_chats ON employee_chats.id = employee_chat_participants.employee_chat_id").
Where("employee_chat_participants.user_id = ?", userID).
Where("employee_chats.location_id IN ?", locationIDs).
Pluck("employee_chat_id", &chatIDs)
}
if len(chatIDs) == 0 {
return 0
}
// Count unread messages across all accessible chats.
// Unread = messages not sent by this user AND no read record for this user.
// `chat_message_reads.deleted_at IS NULL` in the subquery so a
// soft-deleted read row doesn't keep the chat message permanently
// "read" — GORM's automatic scoping doesn't apply to raw EXISTS.
db.Table("chat_messages").
Where("chat_id IN ?", chatIDs).
Where("sender_id != ?", userID).
Where("deleted_at IS NULL").
Where("NOT EXISTS (SELECT 1 FROM chat_message_reads WHERE chat_message_reads.message_id = chat_messages.id AND chat_message_reads.user_id = ? AND chat_message_reads.read_at IS NOT NULL AND chat_message_reads.deleted_at IS NULL)", userID).
Count(&count)
return int(count)
}
// GetUnacknowledgedAbsenceNoticesCount returns the count of absence notifications
// that haven't been acknowledged by staff for children in the employee's groups
func GetUnacknowledgedAbsenceNoticesCount(db *gorm.DB, userID uint) int {
var count int64
// Get user to check role and get their groups
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return 0
}
if !user.IsEmployee() {
return 0
}
// Get employee's groups
var groups []model.Group
db.Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", userID).
Find(&groups)
// For location leads, also include all groups at their locations
if user.IsHouseLeader() {
locationIDs, err := user.GetLocationIDs(db)
if err == nil && len(locationIDs) > 0 {
var locationGroups []model.Group
db.Where("location_id IN ?", locationIDs).Find(&locationGroups)
// Merge groups (avoiding duplicates)
groupIDSet := make(map[uint]bool)
for _, g := range groups {
groupIDSet[g.ID] = true
}
for _, g := range locationGroups {
if !groupIDSet[g.ID] {
groups = append(groups, g)
groupIDSet[g.ID] = true
}
}
}
}
if len(groups) == 0 {
return 0
}
groupIDs := make([]uint, len(groups))
for i, g := range groups {
groupIDs[i] = g.ID
}
// Count unacknowledged absence notifications for children in employee's groups
db.Table("absence_notifications").
Joins("JOIN children ON absence_notifications.child_id = children.id").
Where("children.group_id IN ?", groupIDs).
Where("absence_notifications.acknowledged = ?", false).
Where("absence_notifications.deleted_at IS NULL").
Count(&count)
return int(count)
}
// GetUnseenMessageResponsesCount returns the count of distinct messages created by
// the given employee that have at least one parent answer newer than the last time
// the employee viewed responses.
func GetUnseenMessageResponsesCount(db *gorm.DB, userID uint) int {
var count int64
db.Table("messages").
Select("COUNT(DISTINCT messages.id)").
Where("messages.created_by_id = ?", userID).
Where("messages.draft = ?", false).
Where("messages.deleted_at IS NULL").
Where("messages.interaction_type IN ?", []string{"answer_possible", "answer_required"}).
Where("EXISTS (SELECT 1 FROM message_reads WHERE message_reads.message_id = messages.id AND message_reads.answered_at IS NOT NULL AND message_reads.deleted_at IS NULL AND (messages.answers_last_viewed_at IS NULL OR message_reads.answered_at > messages.answers_last_viewed_at))").
Count(&count)
return int(count)
}
// GetUnseenLetterResponsesCount returns the count of distinct parental letters created by
// the given employee that have at least one parent answer newer than the last time
// the employee viewed responses.
func GetUnseenLetterResponsesCount(db *gorm.DB, userID uint) int {
var count int64
db.Table("parental_letters").
Select("COUNT(DISTINCT parental_letters.id)").
Where("parental_letters.created_by_id = ?", userID).
Where("parental_letters.draft = ?", false).
Where("parental_letters.deleted_at IS NULL").
Where("parental_letters.interaction_type IN ?", []string{"answer_possible", "answer_required"}).
Where("EXISTS (SELECT 1 FROM parental_letter_reads WHERE parental_letter_reads.letter_id = parental_letters.id AND parental_letter_reads.answered_at IS NOT NULL AND parental_letter_reads.deleted_at IS NULL AND (parental_letters.answers_last_viewed_at IS NULL OR parental_letter_reads.answered_at > parental_letters.answers_last_viewed_at))").
Count(&count)
return int(count)
}
// GetUnviewedGroupMessagesCount returns the count of unviewed messages from colleagues
// in the user's assigned groups (Kleinteam). Only counts messages the user hasn't seen
// based on their GroupMessagesLastViewedAt timestamp.
func GetUnviewedGroupMessagesCount(db *gorm.DB, user *model.User) int {
if !user.IsEmployee() {
return 0
}
// Get user's assigned group IDs via group_teachers table
var groupIDs []uint
db.Table("group_teachers").
Where("user_id = ?", user.ID).
Pluck("group_id", &groupIDs)
if len(groupIDs) == 0 {
return 0
}
// Get child IDs in those groups
var childIDs []uint
db.Model(&model.Child{}).
Where("group_id IN ?", groupIDs).
Pluck("id", &childIDs)
if len(childIDs) == 0 {
return 0
}
// Count messages where:
// - created_by_id != user.ID (not user's own messages)
// - draft = false (published messages only)
// - child is in one of user's assigned groups
// - published_at > user.GroupMessagesLastViewedAt (or timestamp is NULL)
query := db.Table("messages").
Where("created_by_id != ?", user.ID).
Where("draft = ?", false).
Where("deleted_at IS NULL").
Where("child_id IN ?", childIDs)
if user.GroupMessagesLastViewedAt != nil {
query = query.Where("published_at > ?", *user.GroupMessagesLastViewedAt)
}
var count int64
query.Count(&count)
return int(count)
}
// GetUnviewedLocationMessagesCount returns the count of unviewed messages from colleagues
// at the user's location(s), excluding their own groups (Grossteam). Only counts messages
// the user hasn't seen based on their LocationMessagesLastViewedAt timestamp.
// Respects the Location.EmployeeLocationAccess setting.
func GetUnviewedLocationMessagesCount(db *gorm.DB, user *model.User) int {
if !user.IsEmployee() {
return 0
}
// Get user's assigned group IDs first (to exclude from location-wide count)
var userGroupIDs []uint
db.Table("group_teachers").
Where("user_id = ?", user.ID).
Pluck("group_id", &userGroupIDs)
// Get user's location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return 0
}
// Check if user has access to location-wide messages based on EmployeeLocationAccess setting
// For each location, check the setting and determine if user can view location messages
var accessibleLocationIDs []uint
var locations []model.Location
db.Where("id IN ?", locationIDs).Find(&locations)
for _, loc := range locations {
// LocationLeads always have access
if user.IsHouseLeader() {
accessibleLocationIDs = append(accessibleLocationIDs, loc.ID)
continue
}
// GroupLeads have access if setting is group_leads or all_employees
if user.IsGroupLeader() {
if loc.EmployeeLocationAccess == model.EmployeeAccessGroupLeads ||
loc.EmployeeLocationAccess == model.EmployeeAccessAllEmployees {
accessibleLocationIDs = append(accessibleLocationIDs, loc.ID)
}
continue
}
// Regular employees only have access if setting is all_employees
if loc.EmployeeLocationAccess == model.EmployeeAccessAllEmployees {
accessibleLocationIDs = append(accessibleLocationIDs, loc.ID)
}
}
if len(accessibleLocationIDs) == 0 {
return 0
}
// Get all group IDs at accessible locations
var locationGroupIDs []uint
db.Model(&model.Group{}).
Where("location_id IN ?", accessibleLocationIDs).
Pluck("id", &locationGroupIDs)
if len(locationGroupIDs) == 0 {
return 0
}
// Exclude user's own groups to avoid double-counting with group messages
var otherGroupIDs []uint
userGroupIDSet := make(map[uint]bool)
for _, id := range userGroupIDs {
userGroupIDSet[id] = true
}
for _, id := range locationGroupIDs {
if !userGroupIDSet[id] {
otherGroupIDs = append(otherGroupIDs, id)
}
}
if len(otherGroupIDs) == 0 {
return 0
}
// Get child IDs in those other groups
var childIDs []uint
db.Model(&model.Child{}).
Where("group_id IN ?", otherGroupIDs).
Pluck("id", &childIDs)
if len(childIDs) == 0 {
return 0
}
// Count messages where:
// - created_by_id != user.ID (not user's own messages)
// - draft = false (published messages only)
// - child is in location's groups (excluding user's direct groups)
// - published_at > user.LocationMessagesLastViewedAt (or timestamp is NULL)
query := db.Table("messages").
Where("created_by_id != ?", user.ID).
Where("draft = ?", false).
Where("deleted_at IS NULL").
Where("child_id IN ?", childIDs)
if user.LocationMessagesLastViewedAt != nil {
query = query.Where("published_at > ?", *user.LocationMessagesLastViewedAt)
}
var count int64
query.Count(&count)
return int(count)
}
// CanViewLocationMessages checks if a user has access to view location-wide messages
// based on the EmployeeLocationAccess settings of their locations.
func CanViewLocationMessages(db *gorm.DB, user *model.User) bool {
if !user.IsEmployee() {
return false
}
// LocationLeads always have access
if user.IsHouseLeader() {
return true
}
// Get user's location IDs
locationIDs, err := user.GetLocationIDs(db)
if err != nil || len(locationIDs) == 0 {
return false
}
// Check if any location allows this user to view location messages
var locations []model.Location
db.Where("id IN ?", locationIDs).Find(&locations)
for _, loc := range locations {
if user.IsGroupLeader() {
if loc.EmployeeLocationAccess == model.EmployeeAccessGroupLeads ||
loc.EmployeeLocationAccess == model.EmployeeAccessAllEmployees {
return true
}
} else if loc.EmployeeLocationAccess == model.EmployeeAccessAllEmployees {
return true
}
}
return false
}
package service
import (
"log"
"time"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// UserChildInfo contains child info with relationship metadata from UserChild table
type UserChildInfo struct {
Child model.Child
RelationshipRole model.RelationshipRole
ValidFrom *time.Time
ValidUntil *time.Time
}
// UserSortOption defines how to sort users
type UserSortOption struct {
Field string // email, name, status, children, role
Desc bool // true for descending order
}
// GetAllUsers returns all users with their roles preloaded
func GetAllUsers(db *gorm.DB) ([]model.User, error) {
var users []model.User
err := db.Preload("Roles").Preload("Children").Order("email ASC").Find(&users).Error
return users, err
}
// GetAllUsersSorted returns all users sorted by the specified option
func GetAllUsersSorted(db *gorm.DB, sort UserSortOption) ([]model.User, error) {
var users []model.User
query := db.Preload("Roles").Preload("Children")
query = applyUserSort(query, sort)
err := query.Find(&users).Error
// For children count and role sorting, we need to sort in Go after loading
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// applyUserSort applies the sort order to the query
func applyUserSort(query *gorm.DB, sort UserSortOption) *gorm.DB {
direction := "ASC"
if sort.Desc {
direction = "DESC"
}
switch sort.Field {
case "name":
return query.Order("last_name " + direction + ", first_name " + direction)
case "status":
// Sort by activated status (active first when ASC)
if sort.Desc {
return query.Order("CASE WHEN activated_at IS NOT NULL THEN 0 ELSE 1 END DESC, email ASC")
}
return query.Order("CASE WHEN activated_at IS NOT NULL THEN 0 ELSE 1 END ASC, email ASC")
case "children":
// Children count requires post-query sorting, just order by email for now
return query.Order("email ASC")
case "role":
// Sort by most privileged role: Admin > LocationLead > GroupLead > Employee > Parent
return query.Order("email " + direction)
default: // email
return query.Order("email " + direction)
}
}
// sortUsersByChildrenCount sorts users slice by children count
func sortUsersByChildrenCount(users []model.User, desc bool) {
for i := 0; i < len(users)-1; i++ {
for j := i + 1; j < len(users); j++ {
countI := len(users[i].Children)
countJ := len(users[j].Children)
swap := false
if desc {
swap = countJ > countI
} else {
swap = countI > countJ
}
if swap {
users[i], users[j] = users[j], users[i]
}
}
}
}
// rolePriority returns a numeric priority for a role (higher = more privileged)
func rolePriority(roleName string) int {
priorities := map[string]int{
"Admin": 5,
"LocationLead": 4,
"GroupLead": 3,
"Employee": 2,
"Parent": 1,
"Anonymous": 0,
}
if p, ok := priorities[roleName]; ok {
return p
}
return 0
}
// getUserHighestRolePriority returns the highest role priority for a user
func getUserHighestRolePriority(user *model.User) int {
maxPriority := 0
for _, role := range user.Roles {
p := rolePriority(role.Name)
if p > maxPriority {
maxPriority = p
}
}
return maxPriority
}
// sortUsersByRole sorts users by their highest privilege role
func sortUsersByRole(users []model.User, desc bool) {
for i := 0; i < len(users)-1; i++ {
for j := i + 1; j < len(users); j++ {
priorityI := getUserHighestRolePriority(&users[i])
priorityJ := getUserHighestRolePriority(&users[j])
swap := false
if desc {
// Descending: highest privilege first
swap = priorityJ > priorityI
} else {
// Ascending: lowest privilege first
swap = priorityI > priorityJ
}
if swap {
users[i], users[j] = users[j], users[i]
}
}
}
}
// GetUsersByLocation returns users connected to a specific location
// via group_teachers (employees) or via children in groups at that location (parents)
func GetUsersByLocation(db *gorm.DB, locationID uint, sort UserSortOption) ([]model.User, error) {
var users []model.User
// Subquery for employees at this location (via group_teachers -> groups)
employeeSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN group_teachers ON users.id = group_teachers.user_id").
Joins("JOIN groups ON group_teachers.group_id = groups.id").
Where("groups.location_id = ?", locationID)
// Subquery for parents with children at this location
parentSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN user_children ON users.id = user_children.user_id").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", locationID)
// Combine: users who are employees OR parents at this location
query := db.Preload("Roles").Preload("Children").
Where("id IN (?) OR id IN (?)", employeeSubquery, parentSubquery)
query = applyUserSort(query, sort)
err := query.Find(&users).Error
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// GetUsersByLocations returns users connected to any of the specified locations
// via group_teachers (employees) or via children in groups at those locations (parents)
func GetUsersByLocations(db *gorm.DB, locationIDs []uint, sort UserSortOption) ([]model.User, error) {
if len(locationIDs) == 0 {
return []model.User{}, nil
}
var users []model.User
// Subquery for employees at these locations (via group_teachers -> groups)
employeeSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN group_teachers ON users.id = group_teachers.user_id").
Joins("JOIN groups ON group_teachers.group_id = groups.id").
Where("groups.location_id IN ?", locationIDs)
// Subquery for parents with children at these locations
parentSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN user_children ON users.id = user_children.user_id").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id IN ?", locationIDs)
// Combine: users who are employees OR parents at these locations
query := db.Preload("Roles").Preload("Children").
Where("id IN (?) OR id IN (?)", employeeSubquery, parentSubquery)
query = applyUserSort(query, sort)
err := query.Find(&users).Error
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// GetEmployeesWithoutGroups returns employees (users with Employee, GroupLead, or LocationLead roles)
// who have NOT been assigned to any groups (no entries in group_teachers table)
// This is useful for finding newly registered employees who need group assignment
func GetEmployeesWithoutGroups(db *gorm.DB, sort UserSortOption) ([]model.User, error) {
var users []model.User
// Subquery: get user IDs that ARE in group_teachers (assigned to at least one group)
assignedUsersSubquery := db.Table("group_teachers").
Select("DISTINCT user_id")
// Main query: users with staff roles who are NOT in the assigned users list
query := db.Preload("Roles").Preload("Children").
Select("users.*").
Joins("JOIN user_roles ON users.id = user_roles.user_id").
Joins("JOIN roles ON user_roles.role_id = roles.id").
Where("roles.name IN ?", []string{"Employee", "GroupLead", "LocationLead"}).
Where("users.id NOT IN (?)", assignedUsersSubquery).
Distinct()
query = applyUserSort(query, sort)
err := query.Find(&users).Error
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// SearchUsers searches users by email, first name, or last name
// If locationID is not nil, filters to users connected to that location
func SearchUsers(db *gorm.DB, searchQuery string, locationID *uint, sort UserSortOption) ([]model.User, error) {
var users []model.User
searchPattern := "%" + searchQuery + "%"
baseQuery := db.Preload("Roles").Preload("Children").
Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ?",
searchPattern, searchPattern, searchPattern)
if locationID != nil && *locationID > 0 {
// Subquery for employees at this location
employeeSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN group_teachers ON users.id = group_teachers.user_id").
Joins("JOIN groups ON group_teachers.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
// Subquery for parents with children at this location
parentSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN user_children ON users.id = user_children.user_id").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
baseQuery = baseQuery.Where("id IN (?) OR id IN (?)", employeeSubquery, parentSubquery)
}
baseQuery = applyUserSort(baseQuery, sort)
err := baseQuery.Find(&users).Error
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// SearchUsersByLocations searches users by email, first name, or last name
// scoped to users connected to any of the specified locations
func SearchUsersByLocations(db *gorm.DB, searchQuery string, locationIDs []uint, sort UserSortOption) ([]model.User, error) {
if len(locationIDs) == 0 {
return []model.User{}, nil
}
var users []model.User
searchPattern := "%" + searchQuery + "%"
// Subquery for employees at these locations
employeeSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN group_teachers ON users.id = group_teachers.user_id").
Joins("JOIN groups ON group_teachers.group_id = groups.id").
Where("groups.location_id IN ?", locationIDs)
// Subquery for parents with children at these locations
parentSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN user_children ON users.id = user_children.user_id").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id IN ?", locationIDs)
baseQuery := db.Preload("Roles").Preload("Children").
Where("(email LIKE ? OR first_name LIKE ? OR last_name LIKE ?)",
searchPattern, searchPattern, searchPattern).
Where("(id IN (?) OR id IN (?))", employeeSubquery, parentSubquery)
baseQuery = applyUserSort(baseQuery, sort)
err := baseQuery.Find(&users).Error
if sort.Field == "children" {
sortUsersByChildrenCount(users, sort.Desc)
} else if sort.Field == "role" {
sortUsersByRole(users, sort.Desc)
}
return users, err
}
// GetUserByID returns a user by ID with roles preloaded
func GetUserByID(db *gorm.DB, userID uint) (*model.User, error) {
var user model.User
err := db.Preload("Roles").Preload("Children").First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserWithChildren returns a user with full child relationship data
// including UserChild records with relationship roles and validity dates
func GetUserWithChildren(db *gorm.DB, userID uint) (*model.User, []UserChildInfo, error) {
// Get user with basic info
var user model.User
err := db.Preload("Roles").First(&user, userID).Error
if err != nil {
return nil, nil, err
}
// Get UserChild records with child data
var userChildren []model.UserChild
err = db.Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Where("user_id = ?", userID).
Find(&userChildren).Error
if err != nil {
return nil, nil, err
}
// Convert to UserChildInfo
childrenInfo := make([]UserChildInfo, len(userChildren))
for i, uc := range userChildren {
childrenInfo[i] = UserChildInfo{
Child: uc.Child,
RelationshipRole: uc.RelationshipRole,
ValidFrom: uc.ValidFrom,
ValidUntil: uc.ValidUntil,
}
}
return &user, childrenInfo, nil
}
// GetAllRoles returns all available roles
func GetAllRoles(db *gorm.DB) ([]model.Role, error) {
var roles []model.Role
err := db.Order("id ASC").Find(&roles).Error
return roles, err
}
// UpdateUserRoles replaces user's roles with the provided role IDs
func UpdateUserRoles(db *gorm.DB, userID uint, roleIDs []uint) error {
var user model.User
if err := db.First(&user, userID).Error; err != nil {
return err
}
// Get roles by IDs
var roles []model.Role
if len(roleIDs) > 0 {
if err := db.Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
return err
}
}
// Replace association
return db.Model(&user).Association("Roles").Replace(roles)
}
// UserHasRole checks if a user has a specific role by name
// Used in templates for role checkbox checking
func UserHasRole(user *model.User, roleName string) bool {
for _, role := range user.Roles {
if role.Name == roleName {
return true
}
}
return false
}
// ========== UserChild Management Functions ==========
// GetUserChildRelationship returns a specific user-child relationship
func GetUserChildRelationship(db *gorm.DB, userID, childID uint) (*model.UserChild, error) {
var uc model.UserChild
err := db.Preload("Child").Preload("Child.Group").
Where("user_id = ? AND child_id = ?", userID, childID).
First(&uc).Error
if err != nil {
return nil, err
}
return &uc, nil
}
// AddUserChildRelationship creates a new user-child relationship
func AddUserChildRelationship(db *gorm.DB, userID, childID uint, role model.RelationshipRole, validFrom, validUntil *time.Time) error {
uc := model.UserChild{
UserID: userID,
ChildID: childID,
RelationshipRole: role,
ValidFrom: validFrom,
ValidUntil: validUntil,
}
return db.Create(&uc).Error
}
// UpdateUserChildRelationship updates an existing user-child relationship
func UpdateUserChildRelationship(db *gorm.DB, userID, childID uint, role model.RelationshipRole, validFrom, validUntil *time.Time) error {
return db.Model(&model.UserChild{}).
Where("user_id = ? AND child_id = ?", userID, childID).
Updates(map[string]interface{}{
"relationship_role": role,
"valid_from": validFrom,
"valid_until": validUntil,
}).Error
}
// DeleteUserChildRelationship removes a user-child relationship
func DeleteUserChildRelationship(db *gorm.DB, userID, childID uint) error {
return db.Where("user_id = ? AND child_id = ?", userID, childID).
Delete(&model.UserChild{}).Error
}
// GetAllChildren returns all children, optionally filtered by location
func GetAllChildren(db *gorm.DB, locationID *uint) ([]model.Child, error) {
var children []model.Child
query := db.Preload("Group").Preload("Group.Location")
if locationID != nil && *locationID > 0 {
query = query.Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
}
err := query.Order("last_name ASC, first_name ASC").Find(&children).Error
return children, err
}
// GetAvailableChildrenForUser returns children not yet assigned to a user
func GetAvailableChildrenForUser(db *gorm.DB, userID uint, locationID *uint) ([]model.Child, error) {
var children []model.Child
query := db.Preload("Group", func(db *gorm.DB) *gorm.DB {
return db.Preload("Location")
})
// Subquery for children already assigned to this user
assignedSubquery := db.Table("user_children").
Select("child_id").
Where("user_id = ?", userID)
query = query.Where("children.id NOT IN (?)", assignedSubquery)
if locationID != nil && *locationID > 0 {
query = query.Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
}
err := query.Order("last_name ASC, first_name ASC").Find(&children).Error
return children, err
}
// GetAllRelationshipRoles returns all available relationship roles
func GetAllRelationshipRoles() []model.RelationshipRole {
return []model.RelationshipRole{
model.RelationshipMother,
model.RelationshipFather,
model.RelationshipStepmother,
model.RelationshipStepfather,
model.RelationshipGrandmother,
model.RelationshipGrandfather,
model.RelationshipGuardian,
model.RelationshipFoster,
model.RelationshipOther,
}
}
// UserChildRelationshipInfo contains full information about a user-child relationship
// including user, child, and relationship details
type UserChildRelationshipInfo struct {
UserChild model.UserChild
User model.User
Child model.Child
}
// GetUserChildRelationshipsByLocation returns all user-child relationships
// for children at the specified locations
func GetUserChildRelationshipsByLocation(db *gorm.DB, locationIDs []uint) ([]UserChildRelationshipInfo, error) {
var userChildren []model.UserChild
query := db.Preload("User").Preload("User.Roles").
Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Joins("JOIN users ON user_children.user_id = users.id")
if len(locationIDs) > 0 {
query = query.Where("groups.location_id IN ?", locationIDs)
}
err := query.Order("children.last_name ASC, children.first_name ASC, users.email ASC").
Find(&userChildren).Error
if err != nil {
return nil, err
}
// Convert to info structs
result := make([]UserChildRelationshipInfo, len(userChildren))
for i, uc := range userChildren {
result[i] = UserChildRelationshipInfo{
UserChild: uc,
User: uc.User,
Child: uc.Child,
}
}
return result, nil
}
// SearchUserChildRelationshipsByLocation searches user-child relationships
// for children at the specified locations, filtering by child name
func SearchUserChildRelationshipsByLocation(db *gorm.DB, locationIDs []uint, searchQuery string) ([]UserChildRelationshipInfo, error) {
var userChildren []model.UserChild
query := db.Preload("User").Preload("User.Roles").
Preload("Child").Preload("Child.Group").Preload("Child.Group.Location").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Joins("JOIN users ON user_children.user_id = users.id")
if len(locationIDs) > 0 {
query = query.Where("groups.location_id IN ?", locationIDs)
}
// Search by child first name or last name (case-insensitive)
searchPattern := "%" + searchQuery + "%"
query = query.Where("LOWER(children.first_name) LIKE LOWER(?) OR LOWER(children.last_name) LIKE LOWER(?)",
searchPattern, searchPattern)
err := query.Order("children.last_name ASC, children.first_name ASC, users.email ASC").
Find(&userChildren).Error
if err != nil {
return nil, err
}
// Convert to info structs
result := make([]UserChildRelationshipInfo, len(userChildren))
for i, uc := range userChildren {
result[i] = UserChildRelationshipInfo{
UserChild: uc,
User: uc.User,
Child: uc.Child,
}
}
return result, nil
}
// ========== Admin Child Management Functions ==========
// ChildUserInfo contains user info with relationship metadata from UserChild table
// (inverse of UserChildInfo - for viewing parents from child perspective)
type ChildUserInfo struct {
User model.User
RelationshipRole model.RelationshipRole
ValidFrom *time.Time
ValidUntil *time.Time
}
// ChildSortOption defines how to sort children
type ChildSortOption struct {
Field string // name, birthday, group, status, parents
Desc bool // true for descending order
}
// GetAllChildrenSorted returns all children sorted by the specified option
func GetAllChildrenSorted(db *gorm.DB, locationID *uint, sort ChildSortOption) ([]model.Child, error) {
var children []model.Child
query := db.Preload("Group").Preload("Group.Location").Preload("Users")
if locationID != nil && *locationID > 0 {
query = query.Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
}
query = applyChildSort(query, sort)
err := query.Find(&children).Error
// For parents count sorting, we need to sort in Go after loading
if sort.Field == "parents" {
sortChildrenByParentsCount(children, sort.Desc)
}
return children, err
}
// applyChildSort applies the sort order to the query
func applyChildSort(query *gorm.DB, sort ChildSortOption) *gorm.DB {
direction := "ASC"
if sort.Desc {
direction = "DESC"
}
switch sort.Field {
case "birthday":
return query.Order("birthday " + direction)
case "group":
return query.Joins("LEFT JOIN groups g ON children.group_id = g.id").
Order("g.name " + direction + ", last_name ASC, first_name ASC")
case "status":
// Sort by active status
if sort.Desc {
return query.Order("CASE WHEN active THEN 0 ELSE 1 END DESC, last_name ASC, first_name ASC")
}
return query.Order("CASE WHEN active THEN 0 ELSE 1 END ASC, last_name ASC, first_name ASC")
case "parents":
// Parents count requires post-query sorting
return query.Order("last_name ASC, first_name ASC")
default: // name
return query.Order("last_name " + direction + ", first_name " + direction)
}
}
// sortChildrenByParentsCount sorts children slice by parents count
func sortChildrenByParentsCount(children []model.Child, desc bool) {
for i := 0; i < len(children)-1; i++ {
for j := i + 1; j < len(children); j++ {
countI := len(children[i].Users)
countJ := len(children[j].Users)
swap := false
if desc {
swap = countJ > countI
} else {
swap = countI > countJ
}
if swap {
children[i], children[j] = children[j], children[i]
}
}
}
}
// SearchChildren searches children by first name or last name
// If locationID is not nil, filters to children at that location
func SearchChildren(db *gorm.DB, searchQuery string, locationID *uint, sort ChildSortOption) ([]model.Child, error) {
var children []model.Child
searchPattern := "%" + searchQuery + "%"
query := db.Preload("Group").Preload("Group.Location").Preload("Users").
Where("first_name LIKE ? OR last_name LIKE ?", searchPattern, searchPattern)
if locationID != nil && *locationID > 0 {
query = query.Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
}
query = applyChildSort(query, sort)
err := query.Find(&children).Error
if sort.Field == "parents" {
sortChildrenByParentsCount(children, sort.Desc)
}
return children, err
}
// GetChildByID returns a child by ID with group preloaded
func GetChildByID(db *gorm.DB, childID uint) (*model.Child, error) {
var child model.Child
err := db.Preload("Group").Preload("Group.Location").First(&child, childID).Error
if err != nil {
return nil, err
}
return &child, nil
}
// GetChildWithParents returns a child with full parent relationship data
// including UserChild records with relationship roles and validity dates
func GetChildWithParents(db *gorm.DB, childID uint) (*model.Child, []ChildUserInfo, error) {
// Get child with basic info
var child model.Child
err := db.Preload("Group").Preload("Group.Location").First(&child, childID).Error
if err != nil {
return nil, nil, err
}
// Get UserChild records with user data
var userChildren []model.UserChild
err = db.Preload("User").Preload("User.Roles").
Where("child_id = ?", childID).
Find(&userChildren).Error
if err != nil {
return nil, nil, err
}
// Convert to ChildUserInfo
parentsInfo := make([]ChildUserInfo, len(userChildren))
for i, uc := range userChildren {
parentsInfo[i] = ChildUserInfo{
User: uc.User,
RelationshipRole: uc.RelationshipRole,
ValidFrom: uc.ValidFrom,
ValidUntil: uc.ValidUntil,
}
}
return &child, parentsInfo, nil
}
// CreateChild creates a new child
func CreateChild(db *gorm.DB, child *model.Child) error {
return db.Create(child).Error
}
// UpdateChild updates child information
func UpdateChild(db *gorm.DB, child *model.Child) error {
return db.Save(child).Error
}
// GetAvailableUsersForChild returns users (parents) not yet assigned to a child
// Optionally filters by location (users who are parents of children at that location)
func GetAvailableUsersForChild(db *gorm.DB, childID uint, locationID *uint) ([]model.User, error) {
var users []model.User
query := db.Preload("Roles")
// Subquery for users already assigned to this child
assignedSubquery := db.Table("user_children").
Select("user_id").
Where("child_id = ?", childID)
query = query.Where("users.id NOT IN (?)", assignedSubquery)
// Only include users who have the Parent role
query = query.Joins("JOIN user_roles ON users.id = user_roles.user_id").
Joins("JOIN roles ON user_roles.role_id = roles.id").
Where("roles.name = ?", "Parent")
if locationID != nil && *locationID > 0 {
// Filter to parents who have children at this location
parentSubquery := db.Table("users").
Select("DISTINCT users.id").
Joins("JOIN user_children ON users.id = user_children.user_id").
Joins("JOIN children ON user_children.child_id = children.id").
Joins("JOIN groups ON children.group_id = groups.id").
Where("groups.location_id = ?", *locationID)
query = query.Where("users.id IN (?)", parentSubquery)
}
err := query.Distinct().Order("last_name ASC, first_name ASC, email ASC").Find(&users).Error
return users, err
}
// GetChildUserRelationship returns a specific child-user relationship
// (alias for GetUserChildRelationship from child's perspective)
func GetChildUserRelationship(db *gorm.DB, childID, userID uint) (*model.UserChild, error) {
var uc model.UserChild
err := db.Preload("User").Preload("User.Roles").
Where("child_id = ? AND user_id = ?", childID, userID).
First(&uc).Error
if err != nil {
return nil, err
}
return &uc, nil
}
// AddChildUserRelationship creates a new child-user relationship (from child perspective)
func AddChildUserRelationship(db *gorm.DB, childID, userID uint, role model.RelationshipRole, validFrom, validUntil *time.Time) error {
uc := model.UserChild{
ChildID: childID,
UserID: userID,
RelationshipRole: role,
ValidFrom: validFrom,
ValidUntil: validUntil,
}
return db.Create(&uc).Error
}
// UpdateChildUserRelationship updates an existing child-user relationship
func UpdateChildUserRelationship(db *gorm.DB, childID, userID uint, role model.RelationshipRole, validFrom, validUntil *time.Time) error {
return db.Model(&model.UserChild{}).
Where("child_id = ? AND user_id = ?", childID, userID).
Updates(map[string]interface{}{
"relationship_role": role,
"valid_from": validFrom,
"valid_until": validUntil,
}).Error
}
// DeleteChildUserRelationship removes a child-user relationship
func DeleteChildUserRelationship(db *gorm.DB, childID, userID uint) error {
return db.Where("child_id = ? AND user_id = ?", childID, userID).
Delete(&model.UserChild{}).Error
}
// GetAllGroupsFiltered returns all groups, optionally filtered by location
func GetAllGroupsFiltered(db *gorm.DB, locationID *uint) ([]model.Group, error) {
var groups []model.Group
query := db.Preload("Location")
if locationID != nil && *locationID > 0 {
query = query.Where("location_id = ?", *locationID)
}
err := query.Order("name ASC").Find(&groups).Error
return groups, err
}
// GetGroupsByLocationIDs returns all groups for multiple location IDs
func GetGroupsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]model.Group, error) {
var groups []model.Group
if len(locationIDs) == 0 {
return groups, nil
}
err := db.Preload("Location").
Where("location_id IN ?", locationIDs).
Order("name ASC").
Find(&groups).Error
return groups, err
}
// ========== User-Group Management Functions (for Employee Group Assignment) ==========
// [impl->dsn~zugriffsmanagement-design~1]
// GetUserGroups returns groups assigned to a user via group_teachers table
func GetUserGroups(db *gorm.DB, userID uint) ([]model.Group, error) {
var groups []model.Group
err := db.Preload("Location").
Joins("JOIN group_teachers ON groups.id = group_teachers.group_id").
Where("group_teachers.user_id = ?", userID).
Order("groups.name ASC").
Find(&groups).Error
return groups, err
}
// GetAvailableGroupsForUser returns groups not yet assigned to user, filtered by location
func GetAvailableGroupsForUser(db *gorm.DB, userID uint, locationID *uint) ([]model.Group, error) {
var groups []model.Group
query := db.Preload("Location")
// Subquery for groups already assigned to this user
assignedSubquery := db.Table("group_teachers").
Select("group_id").
Where("user_id = ?", userID)
query = query.Where("id NOT IN (?)", assignedSubquery)
if locationID != nil && *locationID > 0 {
query = query.Where("location_id = ?", *locationID)
}
err := query.Order("name ASC").Find(&groups).Error
return groups, err
}
// AddUserToGroup assigns a user to a group via group_teachers table
func AddUserToGroup(db *gorm.DB, userID uint, groupID uint) error {
// Use raw SQL insert since group_teachers is a simple join table without a model
return db.Exec("INSERT INTO group_teachers (user_id, group_id) VALUES (?, ?)", userID, groupID).Error
}
// RemoveUserFromGroup removes a user from a group via group_teachers table
func RemoveUserFromGroup(db *gorm.DB, userID uint, groupID uint) error {
return db.Exec("DELETE FROM group_teachers WHERE user_id = ? AND group_id = ?", userID, groupID).Error
}
// ========== Lead Role Assignment Functions ==========
// EnsureUserHasRole adds a role to a user if they don't already have it.
// Unlike UpdateUserRoles which replaces all roles, this is additive.
func EnsureUserHasRole(db *gorm.DB, userID uint, roleName string) error {
var user model.User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return err
}
// Check if user already has the role
for _, role := range user.Roles {
if role.Name == roleName {
return nil // Already has the role
}
}
// Find the role
var role model.Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return err
}
// Add the role
return db.Model(&user).Association("Roles").Append(&role)
}
// EnsureUserInGroup adds a user to a group via group_teachers if not already present.
func EnsureUserInGroup(db *gorm.DB, userID uint, groupID uint) error {
var count int64
if err := db.Table("group_teachers").
Where("user_id = ? AND group_id = ?", userID, groupID).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil // Already in group
}
return db.Exec("INSERT INTO group_teachers (user_id, group_id) VALUES (?, ?)", userID, groupID).Error
}
// AssignLeadRolesForEmployee checks sync_group_leads and sync_location_leads for a given
// employee external ID and assigns the appropriate roles/relationships.
// Called from both registration (immediate) and sync processor (batch).
func AssignLeadRolesForEmployee(db *gorm.DB, userID uint, externalID string) error {
now := time.Now()
// Process group lead assignments
var groupLeads []model.SyncGroupLead
if err := db.Where("mitarbeiter_id = ?", externalID).
Where("g_von IS NULL OR g_von <= ?", now).
Where("g_bis IS NULL OR g_bis >= ?", now).
Find(&groupLeads).Error; err != nil {
return err
}
for _, gl := range groupLeads {
// Find the group by external ID
var group model.Group
if err := db.Where("external_id = ?", gl.GruppenID).First(&group).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[LEAD-ASSIGN] Group %s not found for group lead %s, skipping", gl.GruppenID, externalID)
continue
}
return err
}
// Assign GroupLead role
if err := EnsureUserHasRole(db, userID, "GroupLead"); err != nil {
return err
}
// Set Group.LeadId
if err := db.Model(&group).Update("lead_id", userID).Error; err != nil {
return err
}
// Add to group_teachers for this specific group
if err := EnsureUserInGroup(db, userID, group.ID); err != nil {
return err
}
log.Printf("[LEAD-ASSIGN] Assigned GroupLead: UserID=%d GroupID=%d ExternalGroupID=%s", userID, group.ID, gl.GruppenID)
}
// Process location lead assignments
var locationLeads []model.SyncLocationLead
if err := db.Where("mitarbeiter_id = ?", externalID).
Where("g_von IS NULL OR g_von <= ?", now).
Where("g_bis IS NULL OR g_bis >= ?", now).
Find(&locationLeads).Error; err != nil {
return err
}
for _, ll := range locationLeads {
// Find the location by external ID
var location model.Location
if err := db.Where("external_id = ?", ll.EinrichtungsID).First(&location).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[LEAD-ASSIGN] Location %s not found for location lead %s, skipping", ll.EinrichtungsID, externalID)
continue
}
return err
}
// Assign LocationLead role
if err := EnsureUserHasRole(db, userID, "LocationLead"); err != nil {
return err
}
// Set Location.LeadId
if err := db.Model(&location).Update("lead_id", userID).Error; err != nil {
return err
}
log.Printf("[LEAD-ASSIGN] Assigned LocationLead: UserID=%d LocationID=%d ExternalLocationID=%s", userID, location.ID, ll.EinrichtungsID)
}
// Process deputy (Stellvertreter) assignments from location leads where this user is the deputy
var deputyLeads []model.SyncLocationLead
if err := db.Where("stellvertreter_id = ?", externalID).
Where("g_von IS NULL OR g_von <= ?", now).
Where("g_bis IS NULL OR g_bis >= ?", now).
Find(&deputyLeads).Error; err != nil {
return err
}
for _, dl := range deputyLeads {
var location model.Location
if err := db.Where("external_id = ?", dl.EinrichtungsID).First(&location).Error; err != nil {
if err == gorm.ErrRecordNotFound {
log.Printf("[LEAD-ASSIGN] Location %s not found for deputy %s, skipping", dl.EinrichtungsID, externalID)
continue
}
return err
}
// Assign LocationLead role to deputy
if err := EnsureUserHasRole(db, userID, "LocationLead"); err != nil {
return err
}
// Set Location.Lead2ndId
if err := db.Model(&location).Update("lead2nd_id", userID).Error; err != nil {
return err
}
log.Printf("[LEAD-ASSIGN] Assigned Deputy LocationLead: UserID=%d LocationID=%d ExternalLocationID=%s", userID, location.ID, dl.EinrichtungsID)
}
return nil
}
package storage
// File storage abstraction for attachments
// Currently uses PostgreSQL BLOB storage via GORM
// Can be extended to use filesystem or S3 in the future
import (
"errors"
"net/http"
"strings"
"wippidu_app_backend/internal/model"
"gorm.io/gorm"
)
// Configuration constants
const (
MaxFileSize = 10 * 1024 * 1024 // 10 MB per file
MaxTotalSize = 30 * 1024 * 1024 // 30 MB total per letter
MaxAttachmentsCount = 5 // Max 5 attachments per letter
)
// AllowedMimeTypes defines the permitted file types
var AllowedMimeTypes = map[string]bool{
"application/pdf": true,
"image/png": true,
"image/jpeg": true,
"image/gif": true,
}
// AllowedExtensions maps extensions to expected MIME types
var AllowedExtensions = map[string]string{
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
}
// Common errors
var (
ErrFileTooLarge = errors.New("file exceeds maximum size limit")
ErrInvalidFileType = errors.New("file type not allowed")
ErrTooManyFiles = errors.New("too many attachments")
ErrTotalSizeExceeded = errors.New("total attachment size exceeded")
ErrAttachmentNotFound = errors.New("attachment not found")
)
// FileStorage defines the interface for file storage operations
type FileStorage interface {
// Store saves a file and returns the attachment ID
Store(letterID uint, filename string, mimeType string, content []byte) (uint, error)
// StoreOrphan saves a file without associating it to a letter (for AJAX uploads before letter is created)
StoreOrphan(userID uint, filename string, mimeType string, content []byte) (uint, error)
// AssociateOrphans associates orphan attachments with a letter
AssociateOrphans(attachmentIDs []uint, letterID uint) error
// Retrieve fetches a file by attachment ID
Retrieve(id uint) ([]byte, string, string, error) // content, mimeType, filename, error
// Delete removes a file by attachment ID
Delete(id uint) error
// GetByLetter returns all attachments for a letter
GetByLetter(letterID uint) ([]model.Attachment, error)
// CountByLetter returns the number of attachments for a letter
CountByLetter(letterID uint) (int64, error)
// TotalSizeByLetter returns the total size of attachments for a letter
TotalSizeByLetter(letterID uint) (int64, error)
// GetAttachment returns a single attachment by ID (without content)
GetAttachment(id uint) (*model.Attachment, error)
// StoreForNews saves a file associated with a news entry
StoreForNews(newsID uint, filename string, mimeType string, content []byte) (uint, error)
// AssociateOrphansToNews associates orphan attachments with a news entry
AssociateOrphansToNews(attachmentIDs []uint, newsID uint) error
// GetByNews returns all attachments for a news entry (without content)
GetByNews(newsID uint) ([]model.Attachment, error)
// CountByNews returns the number of attachments for a news entry
CountByNews(newsID uint) (int64, error)
// TotalSizeByNews returns the total size of attachments for a news entry
TotalSizeByNews(newsID uint) (int64, error)
}
// DBStorage implements FileStorage using GORM/PostgreSQL
type DBStorage struct {
db *gorm.DB
}
// NewDBStorage creates a new DBStorage instance
func NewDBStorage(db *gorm.DB) *DBStorage {
return &DBStorage{db: db}
}
// Store saves a file to the database
func (s *DBStorage) Store(letterID uint, filename string, mimeType string, content []byte) (uint, error) {
// Validate file size
if int64(len(content)) > MaxFileSize {
return 0, ErrFileTooLarge
}
// Validate MIME type
if !AllowedMimeTypes[mimeType] {
return 0, ErrInvalidFileType
}
// Check attachment count
count, err := s.CountByLetter(letterID)
if err != nil {
return 0, err
}
if count >= MaxAttachmentsCount {
return 0, ErrTooManyFiles
}
// Check total size
totalSize, err := s.TotalSizeByLetter(letterID)
if err != nil {
return 0, err
}
if totalSize+int64(len(content)) > MaxTotalSize {
return 0, ErrTotalSizeExceeded
}
attachment := model.Attachment{
ParentalLetterId: &letterID,
Filename: filename,
MimeType: mimeType,
Size: int64(len(content)),
Content: content,
}
if err := s.db.Create(&attachment).Error; err != nil {
return 0, err
}
return attachment.ID, nil
}
// StoreOrphan saves a file without associating it to a letter (for AJAX uploads before letter is created)
func (s *DBStorage) StoreOrphan(userID uint, filename string, mimeType string, content []byte) (uint, error) {
// Validate file size
if int64(len(content)) > MaxFileSize {
return 0, ErrFileTooLarge
}
// Validate MIME type
if !AllowedMimeTypes[mimeType] {
return 0, ErrInvalidFileType
}
attachment := model.Attachment{
ParentalLetterId: nil,
UploadedById: &userID,
Filename: filename,
MimeType: mimeType,
Size: int64(len(content)),
Content: content,
}
if err := s.db.Create(&attachment).Error; err != nil {
return 0, err
}
return attachment.ID, nil
}
// AssociateOrphans associates orphan attachments with a letter
func (s *DBStorage) AssociateOrphans(attachmentIDs []uint, letterID uint) error {
if len(attachmentIDs) == 0 {
return nil
}
return s.db.Model(&model.Attachment{}).
Where("id IN ? AND parental_letter_id IS NULL", attachmentIDs).
Update("parental_letter_id", letterID).Error
}
// Retrieve fetches a file from the database
func (s *DBStorage) Retrieve(id uint) ([]byte, string, string, error) {
var attachment model.Attachment
if err := s.db.First(&attachment, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", ErrAttachmentNotFound
}
return nil, "", "", err
}
return attachment.Content, attachment.MimeType, attachment.Filename, nil
}
// Delete removes a file from the database
func (s *DBStorage) Delete(id uint) error {
result := s.db.Delete(&model.Attachment{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrAttachmentNotFound
}
return nil
}
// GetByLetter returns all attachments for a letter (without content for listing)
func (s *DBStorage) GetByLetter(letterID uint) ([]model.Attachment, error) {
var attachments []model.Attachment
err := s.db.Select("id, created_at, updated_at, parental_letter_id, filename, mime_type, size").
Where("parental_letter_id = ?", letterID).
Find(&attachments).Error
return attachments, err
}
// CountByLetter returns the number of attachments for a letter
func (s *DBStorage) CountByLetter(letterID uint) (int64, error) {
var count int64
err := s.db.Model(&model.Attachment{}).Where("parental_letter_id = ?", letterID).Count(&count).Error
return count, err
}
// TotalSizeByLetter returns the total size of attachments for a letter
func (s *DBStorage) TotalSizeByLetter(letterID uint) (int64, error) {
var totalSize int64
err := s.db.Model(&model.Attachment{}).
Where("parental_letter_id = ?", letterID).
Select("COALESCE(SUM(size), 0)").
Scan(&totalSize).Error
return totalSize, err
}
// GetAttachment returns a single attachment by ID (without content)
func (s *DBStorage) GetAttachment(id uint) (*model.Attachment, error) {
var attachment model.Attachment
err := s.db.Select("id, created_at, updated_at, parental_letter_id, news_id, uploaded_by_id, filename, mime_type, size").
First(&attachment, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAttachmentNotFound
}
return nil, err
}
return &attachment, nil
}
// StoreForNews saves a file associated with a news entry
func (s *DBStorage) StoreForNews(newsID uint, filename string, mimeType string, content []byte) (uint, error) {
if int64(len(content)) > MaxFileSize {
return 0, ErrFileTooLarge
}
if !AllowedMimeTypes[mimeType] {
return 0, ErrInvalidFileType
}
count, err := s.CountByNews(newsID)
if err != nil {
return 0, err
}
if count >= MaxAttachmentsCount {
return 0, ErrTooManyFiles
}
totalSize, err := s.TotalSizeByNews(newsID)
if err != nil {
return 0, err
}
if totalSize+int64(len(content)) > MaxTotalSize {
return 0, ErrTotalSizeExceeded
}
attachment := model.Attachment{
NewsId: &newsID,
Filename: filename,
MimeType: mimeType,
Size: int64(len(content)),
Content: content,
}
if err := s.db.Create(&attachment).Error; err != nil {
return 0, err
}
return attachment.ID, nil
}
// AssociateOrphansToNews associates orphan attachments with a news entry
func (s *DBStorage) AssociateOrphansToNews(attachmentIDs []uint, newsID uint) error {
if len(attachmentIDs) == 0 {
return nil
}
return s.db.Model(&model.Attachment{}).
Where("id IN ? AND parental_letter_id IS NULL AND news_id IS NULL", attachmentIDs).
Update("news_id", newsID).Error
}
// GetByNews returns all attachments for a news entry (without content)
func (s *DBStorage) GetByNews(newsID uint) ([]model.Attachment, error) {
var attachments []model.Attachment
err := s.db.Select("id, created_at, updated_at, news_id, filename, mime_type, size").
Where("news_id = ?", newsID).
Find(&attachments).Error
return attachments, err
}
// CountByNews returns the number of attachments for a news entry
func (s *DBStorage) CountByNews(newsID uint) (int64, error) {
var count int64
err := s.db.Model(&model.Attachment{}).Where("news_id = ?", newsID).Count(&count).Error
return count, err
}
// TotalSizeByNews returns the total size of attachments for a news entry
func (s *DBStorage) TotalSizeByNews(newsID uint) (int64, error) {
var totalSize int64
err := s.db.Model(&model.Attachment{}).
Where("news_id = ?", newsID).
Select("COALESCE(SUM(size), 0)").
Scan(&totalSize).Error
return totalSize, err
}
// ValidateFile checks if a file is valid for upload
func ValidateFile(filename string, content []byte) (string, error) {
// Check file size
if int64(len(content)) > MaxFileSize {
return "", ErrFileTooLarge
}
// Find the last dot for extension
lastDot := strings.LastIndex(filename, ".")
if lastDot == -1 {
return "", ErrInvalidFileType
}
// Get extension and validate
ext := strings.ToLower(filename[lastDot:])
expectedMime, ok := AllowedExtensions[ext]
if !ok {
return "", ErrInvalidFileType
}
// Detect actual MIME type from content (magic bytes)
detectedMime := http.DetectContentType(content)
// For PDFs, DetectContentType returns "application/pdf" or "application/octet-stream"
// For images, it returns the correct image type
if detectedMime == "application/octet-stream" {
// Check PDF magic bytes manually
if len(content) >= 4 && string(content[:4]) == "%PDF" {
detectedMime = "application/pdf"
}
}
// Validate detected type matches expected type
if !AllowedMimeTypes[detectedMime] {
return "", ErrInvalidFileType
}
// For images, verify extension matches detected type
if strings.HasPrefix(detectedMime, "image/") && detectedMime != expectedMime {
// Allow jpeg/jpg mismatch
if !(detectedMime == "image/jpeg" && expectedMime == "image/jpeg") {
return "", ErrInvalidFileType
}
}
return detectedMime, nil
}
// IsImageType returns true if the MIME type is an image
func IsImageType(mimeType string) bool {
return strings.HasPrefix(mimeType, "image/")
}
// IsPDFType returns true if the MIME type is a PDF
func IsPDFType(mimeType string) bool {
return mimeType == "application/pdf"
}
// FormatFileSize returns a human-readable file size
func FormatFileSize(size int64) string {
const unit = 1024
if size < unit {
return strings.TrimSpace(strings.Replace(string(rune(size))+" B", "\x00", "", -1))
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB"}
return strings.TrimSpace(strings.Replace(
string(rune(size/div))+" "+units[exp], "\x00", "", -1))
}
package testhelpers
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// SetupTestDB creates an in-memory SQLite database for testing
// Returns a database connection ready for use with test models
func SetupTestDB(t *testing.T) *gorm.DB {
// Create a temporary file for the test database
f, err := os.CreateTemp("", "testdb_*.db")
require.NoError(t, err)
dbPath := f.Name()
f.Close()
// Open database connection
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
// Enable constraint enforcement in SQLite
// This ensures UNIQUE, FOREIGN KEY, and other constraints work properly in tests
db.Exec("PRAGMA foreign_keys = ON")
// Store the db path for cleanup
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
sqlDB.Close()
}
os.Remove(dbPath)
})
return db
}
package testhelpers
import (
"encoding/base64"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
)
// TestClaims mimics model.Claims without import cycle
type TestClaims struct {
UserIdent uint `json:"userident"`
UserEmail string `json:"useremail"`
jwt.RegisteredClaims
}
// GenerateTestToken creates a valid JWT token for testing
func GenerateTestToken(t *testing.T, secret string, userID uint, email string) string {
expirationTime := time.Now().Add(7 * 24 * time.Hour)
claims := &TestClaims{
UserIdent: userID,
UserEmail: email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: email,
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := base64.StdEncoding.DecodeString(secret)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
return tokenString
}
// GenerateExpiredToken creates an expired JWT token for testing
func GenerateExpiredToken(t *testing.T, secret string, userID uint, email string) string {
expirationTime := time.Now().Add(-1 * time.Hour) // expired 1 hour ago
claims := &TestClaims{
UserIdent: userID,
UserEmail: email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: email,
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := base64.StdEncoding.DecodeString(secret)
require.NoError(t, err)
tokenString, err := token.SignedString(key)
require.NoError(t, err)
return tokenString
}
// GetTestSecret returns a base64-encoded test secret for JWT signing
func GetTestSecret() string {
// "test-secret-key" base64 encoded
return base64.StdEncoding.EncodeToString([]byte("test-secret-key-for-jwt-testing"))
}
package util
import (
"wippidu_app_backend/internal/model"
"github.com/gin-gonic/gin"
)
// MustUser returns the authenticated user from the gin context, or nil
// if the request is unauthenticated. Replaces the four-line
// type-assertion preamble that controllers were duplicating (~214
// occurrences across the codebase per the #464 audit).
//
// Returns nil rather than panicking so callers can render a sensible
// 401 or redirect, but most call sites should treat a nil return as
// "auth middleware mis-wired" — `IsAuthorized` guarantees the value
// is set for any route past the public surface.
func MustUser(c *gin.Context) *model.User {
v, ok := c.Get("User")
if !ok {
return nil
}
user, ok := v.(*model.User)
if !ok {
return nil
}
return user
}
// Lang returns the request language string, falling back to "de" if
// nothing was stored. Replaces ~216 occurrences of the same
// language-fetch + type-assert + fallback boilerplate in controllers.
//
// `middleware.LanguageDetection` always stores a non-empty string, so
// in practice the fallback only triggers on test paths that bypass
// middleware.
func Lang(c *gin.Context) string {
v, ok := c.Get("language")
if !ok {
return "de"
}
s, ok := v.(string)
if !ok || s == "" {
return "de"
}
return s
}
package util
import (
"fmt"
"os"
"strings"
)
// SecureCookies reports whether session/auth cookies should be marked
// with the Secure attribute. Driven by USE_TLS — when the server is
// terminating TLS, cookies must not be sent over plain HTTP. The
// helper exists so the policy lives in one place rather than being
// hard-coded `false` at every SetCookie site.
//
// Recognises the same truthy values as IsImportEnabled.
func SecureCookies() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("USE_TLS"))) {
case "1", "true", "yes":
return true
}
return false
}
// RequireEnvSecret returns the value of the named environment variable,
// failing with an error if the variable is unset or contains only
// whitespace. Used at startup to refuse booting the server when a
// security-critical secret hasn't been provided.
//
// The error is shaped for operator readability — it names the variable
// and where the deployment convention expects it to be set.
func RequireEnvSecret(name string) (string, error) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return "", fmt.Errorf("required environment variable %s is unset or empty — set it in the deployment env (e.g. deployment/docker/.env.app.production) and restart", name)
}
return v, nil
}
package util
import (
"log"
)
func LogFatal(err error, msg string) {
if err != nil {
log.Fatal(msg)
}
}
package util
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
// ProductionPasswordHashCost is the bcrypt cost used outside of tests.
// 14 gives ~1.5s per hash on a 2026-era CPU — comfortably above
// brute-force economics for the foreseeable future and acceptable
// latency for human-driven login flows.
const ProductionPasswordHashCost = 14
// PasswordHashCost returns the bcrypt cost to use for password hashing.
// Under `go test` it drops to bcrypt.MinCost (4) — that's roughly
// 1000× faster than cost 14, which is why the test suite previously
// needed the "run tests in segments" workaround flagged in claude.md
// (most of the wall-clock time was bcrypt on the seed users).
//
// testing.Testing() is the canonical detector; it returns true only
// when the binary was built by the testing toolchain. Calling it from
// production code is explicitly supported by the stdlib docs.
func PasswordHashCost() int {
if testing.Testing() {
return bcrypt.MinCost
}
return ProductionPasswordHashCost
}
// GenerateHashPassword returns the bcrypt hash of password using the
// cost from PasswordHashCost — strong cost in production, MinCost in
// tests.
func GenerateHashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), PasswordHashCost())
return string(bytes), err
}
// CompareHashPassword reports whether password matches the given
// bcrypt hash.
func CompareHashPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
package util
import (
"os"
"strings"
)
// IsImportEnabled reports whether the running instance is configured to accept
// full-database imports. The use case is refreshing staging from production
// dumps — production must never offer the import action.
//
// Controlled by the APP_ALLOW_PROD_IMPORT env var. Accepted truthy values are
// "1", "true", "yes" (case-insensitive). Any other value (including unset)
// means imports are disabled.
func IsImportEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("APP_ALLOW_PROD_IMPORT"))) {
case "1", "true", "yes":
return true
}
return false
}
package util
import (
"bytes"
"html/template"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
var md goldmark.Markdown
var mdUnsafe goldmark.Markdown
func init() {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM), // GitHub Flavored Markdown
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
mdUnsafe = goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
}
// RenderMarkdownUnsafe converts Markdown text to HTML, allowing raw HTML passthrough.
// Only use for trusted content (e.g. admin-authored FAQ).
func RenderMarkdownUnsafe(source string) template.HTML {
if source == "" {
return template.HTML("")
}
var buf bytes.Buffer
if err := mdUnsafe.Convert([]byte(source), &buf); err != nil {
return template.HTML(template.HTMLEscapeString(source))
}
return template.HTML(buf.String())
}
// RenderMarkdown converts Markdown text to safe HTML
func RenderMarkdown(source string) template.HTML {
if source == "" {
return template.HTML("")
}
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
// Fallback to escaped text on error
return template.HTML(template.HTMLEscapeString(source))
}
return template.HTML(buf.String())
}
package util
import (
"net/http"
"wippidu_app_backend/internal/model"
"wippidu_app_backend/internal/service"
"github.com/gin-gonic/gin"
)
// AddUnreadCounts adds unread notification counts from context to template data
func AddUnreadCounts(c *gin.Context, data gin.H) gin.H {
// Always ensure badges is set (required for template safety)
// Templates access .badges.Something directly, so badges must never be nil
if _, exists := data["badges"]; !exists {
if badges, ok := c.Get("badges"); ok {
data["badges"] = badges
} else {
// Provide default empty badges to prevent nil pointer errors in templates
data["badges"] = service.NotificationBadges{}
}
}
if unreadNews, exists := c.Get("unreadNews"); exists {
data["unreadNews"] = unreadNews
}
if unreadMessages, exists := c.Get("unreadMessages"); exists {
data["unreadMessages"] = unreadMessages
}
if unreadLetters, exists := c.Get("unreadLetters"); exists {
data["unreadLetters"] = unreadLetters
}
// Add pending employee actions count for employee users
if pendingEmployeeActions, exists := c.Get("pendingEmployeeActions"); exists {
data["pendingEmployeeActions"] = pendingEmployeeActions
}
// Add unacknowledged absence notices count for employees
if unacknowledgedAbsenceNotices, exists := c.Get("unacknowledgedAbsenceNotices"); exists {
data["unacknowledgedAbsenceNotices"] = unacknowledgedAbsenceNotices
}
// Add unread chat messages count for employees
if unreadChatMessages, exists := c.Get("unreadChatMessages"); exists {
data["unreadChatMessages"] = unreadChatMessages
}
// Add admin location data for admin users
if allLocations, exists := c.Get("allLocations"); exists {
data["allLocations"] = allLocations
}
if adminLocationId, exists := c.Get("adminLocationId"); exists {
data["adminLocationId"] = adminLocationId
}
// Add active role for dual-role users (Employee+Parent)
if activeRole, exists := c.Get("activeRole"); exists {
data["activeRole"] = activeRole
}
// Add impersonation state for banner display
if isImpersonating, exists := c.Get("isImpersonating"); exists {
data["isImpersonating"] = isImpersonating
}
if originalAdmin, exists := c.Get("originalAdmin"); exists {
data["originalAdmin"] = originalAdmin
}
// Add delegation flags for LocationLead menu visibility
if delegateChildren, exists := c.Get("delegateChildren"); exists {
data["delegateChildren"] = delegateChildren
}
if delegateEnrollments, exists := c.Get("delegateEnrollments"); exists {
data["delegateEnrollments"] = delegateEnrollments
}
if delegateGroups, exists := c.Get("delegateGroups"); exists {
data["delegateGroups"] = delegateGroups
}
if delegateUsers, exists := c.Get("delegateUsers"); exists {
data["delegateUsers"] = delegateUsers
}
// Add intranet sync flags for gear menu visibility
if hasIntranetSync, exists := c.Get("hasIntranetSync"); exists {
data["hasIntranetSync"] = hasIntranetSync
}
if intranetRefreshFailed, exists := c.Get("intranetRefreshFailed"); exists {
data["intranetRefreshFailed"] = intranetRefreshFailed
}
// Last successful import — populated lazily so we don't run the query
// when a controller has already supplied its own value (the
// import/export page uses a richer audit listing).
if _, exists := data["lastImport"]; !exists {
data["lastImport"] = model.LatestSuccessfulImport()
}
// CSRF hidden-input field, produced by the CSRF middleware. Templates
// render it as {{ .csrfField }} inside every POST/PUT/DELETE form so
// the token reaches the server alongside form submissions.
if csrfField, exists := c.Get("csrfField"); exists {
data["csrfField"] = csrfField
}
if csrfToken, exists := c.Get("csrfToken"); exists {
data["csrfToken"] = csrfToken
}
return data
}
// RenderHTML renders an HTML template with unread counts automatically added
func RenderHTML(c *gin.Context, code int, name string, data gin.H) {
data = AddUnreadCounts(c, data)
c.HTML(code, name, data)
}
// RenderHTMLOK is a shorthand for RenderHTML with http.StatusOK
func RenderHTMLOK(c *gin.Context, name string, data gin.H) {
RenderHTML(c, http.StatusOK, name, data)
}
package util
import (
"encoding/base64"
"os"
"wippidu_app_backend/internal/logger"
"wippidu_app_backend/internal/model"
jwt "github.com/golang-jwt/jwt/v5"
)
func ParseToken(tokenString string) (claims *model.Claims, err error) {
token, err := jwt.ParseWithClaims(
tokenString,
&model.Claims{},
func(token *jwt.Token) (interface{}, error) {
return base64.StdEncoding.DecodeString(os.Getenv("APP_SECRET"))
},
)
if err != nil {
logger.Debug("token parse error", "error", err)
return nil, err
}
claims, ok := token.Claims.(*model.Claims)
if !ok {
logger.Debug("token claims type assertion failed")
return nil, err
}
return claims, nil
}