package main import ( "context" "log" "net/http" "os" "os/signal" "syscall" // Embed Go's IANA tz database into the binary so time.LoadLocation works // without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/ // zoneinfo — without this import, every reminder timezone lookup fails // silently and the hourly reminder slot fires in UTC instead of the // user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary. _ "time/tzdata" "mgit.msbls.de/m/paliad/internal/auth" "mgit.msbls.de/m/paliad/internal/branding" "mgit.msbls.de/m/paliad/internal/db" "mgit.msbls.de/m/paliad/internal/handlers" "mgit.msbls.de/m/paliad/internal/services" ) func main() { port := os.Getenv("PORT") if port == "" { port = "8080" } // Surface the firm name in the boot log so a deployer can confirm // FIRM_NAME took effect without curl-ing a rendered page. log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name) supabaseURL := os.Getenv("SUPABASE_URL") supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY") if supabaseURL == "" || supabaseAnonKey == "" { log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set") } jwtSecret := os.Getenv("SUPABASE_JWT_SECRET") if jwtSecret == "" { log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification") } client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret)) giteaToken := os.Getenv("GITEA_TOKEN") if giteaToken == "" { log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos") } // MailService is wired regardless of DB availability — it no-ops when // SMTP env vars are unset, so the server stays runnable for knowledge- // platform-only deployments. Template-parse errors at boot are fatal. mailSvc, err := services.NewMailService() if err != nil { log.Fatalf("mail service init: %v", err) } // Shared context for background goroutines (CalDAV sync + reminder job). bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // DATABASE_URL is optional during the Phase A → Phase D transition. The // existing knowledge-platform features (Kostenrechner, Glossar, etc.) work // without a DB. matter-management endpoints return 503 until DATABASE_URL is set. dbURL := os.Getenv("DATABASE_URL") var svcBundle *handlers.Services var caldavSvc *services.CalDAVService if dbURL != "" { log.Println("applying database migrations…") if err := db.ApplyMigrations(dbURL); err != nil { log.Fatalf("migration failed: %v", err) } log.Println("database migrations applied") pool, err := db.OpenPool(dbURL) if err != nil { log.Fatalf("open db pool: %v", err) } // Refresh paliad.deadline_search whenever migrations run, so // search reflects any newly-seeded rule / concept / trigger. // Migration 047 created the matview already-populated; this // is only a no-op for the boot that introduced it. CONCURRENTLY // keeps reads online and stays well under 100 ms at < 1k rows. if err := services.RefreshSearchView(bgCtx, pool); err != nil { log.Printf("refresh deadline_search: %v", err) } holidays := services.NewHolidayService(pool) users := services.NewUserService(pool) projectSvc := services.NewProjectService(pool, users) teamSvc := services.NewTeamService(pool, projectSvc) partnerUnitSvc := services.NewPartnerUnitService(pool, users) rules := services.NewDeadlineRuleService(pool) // Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset // the service exists but Enabled() reports false; handlers return 501. // If the env var is malformed, fail fast — silently skipping would // leave plaintext-credential bugs hidden. cipher, err := services.LoadCalDAVCipher() if err != nil { log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err) } if cipher == nil { log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501") } else { log.Println("CalDAV encryption configured (AES-256-GCM)") } appointmentSvc := services.NewAppointmentService(pool, projectSvc) caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc) // Wire the push hook so user-driven mutations sync to the external // calendar without waiting for the next 60-second tick. appointmentSvc.SetCalDAVPusher(caldavSvc) baseURL := os.Getenv("PALIAD_BASE_URL") inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL) reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL) // Wire EmailTemplateService onto the MailService so DB-backed admin // edits propagate without a process restart. The constructor is split // from MailService creation because the DB pool isn't available yet // at the point we build mailSvc above. emailTemplateSvc := services.NewEmailTemplateService(pool) mailSvc.SetTemplateService(emailTemplateSvc) eventTypeSvc := services.NewEventTypeService(pool, users) deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc) svcBundle = &handlers.Services{ Project: projectSvc, Team: teamSvc, PartnerUnit: partnerUnitSvc, Party: services.NewPartyService(pool, projectSvc), Deadline: deadlineSvc, Appointment: appointmentSvc, CalDAV: caldavSvc, Rules: rules, Calculator: services.NewDeadlineCalculator(holidays), Users: users, Fristenrechner: services.NewFristenrechnerService(rules, holidays), EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays), DeadlineSearch: services.NewDeadlineSearchService(pool), EventCategory: nil, // wired below; cross-link order matters EventType: eventTypeSvc, Dashboard: services.NewDashboardService(pool, users), Note: services.NewNoteService(pool, projectSvc, appointmentSvc), ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc), Mail: mailSvc, Invite: inviteSvc, Agenda: services.NewAgendaService(pool, users, eventTypeSvc), Audit: services.NewAuditService(pool), EmailTemplate: emailTemplateSvc, Link: services.NewLinkService(pool), Event: services.NewEventService(pool, deadlineSvc, appointmentSvc), } // v3 (t-paliad-133): wire EventCategoryService and cross-link // it into DeadlineSearchService so ?event_category_slug= can // resolve to a concept-id allow-list during search. eventCategorySvc := services.NewEventCategoryService(pool) svcBundle.EventCategory = eventCategorySvc svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc) log.Println("Phase B services initialised") // Spawn background goroutines: CalDAV sync (one per enabled user) // and the hourly reminder scanner. Both live for the process // lifetime; the signal-scoped context cleans them up on SIGTERM. if err := caldavSvc.Start(bgCtx); err != nil { log.Printf("CalDAV start: %v", err) } reminderSvc.Start(bgCtx) go func() { <-bgCtx.Done() log.Println("background services: shutdown signal received") caldavSvc.Stop() }() } else { log.Println("DATABASE_URL not set — matter-management endpoints will return 503") } mux := http.NewServeMux() handlers.Register(mux, client, giteaToken, svcBundle) log.Printf("paliad server starting on :%s", port) if err := http.ListenAndServe(":"+port, mux); err != nil { log.Fatal(err) } }