package main
import (
"context"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"task-dashboard/internal/api"
"task-dashboard/internal/auth"
"task-dashboard/internal/config"
"task-dashboard/internal/handlers"
"task-dashboard/internal/store"
)
func main() {
// Load .env file (ignore error if file doesn't exist - env vars might be set directly)
_ = godotenv.Load()
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
db, err := store.New(cfg.DatabasePath, cfg.MigrationDir)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Initialize session manager
sessionManager := scs.New()
sessionManager.Store = sqlite3store.New(db.DB())
sessionManager.Lifetime = 24 * time.Hour
sessionManager.Cookie.Persist = true
sessionManager.Cookie.Secure = !cfg.Debug
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
// Initialize auth service
authService := auth.NewService(db.DB())
// Ensure default admin user exists (use env vars for credentials)
defaultUser := os.Getenv("DEFAULT_USER")
defaultPass := os.Getenv("DEFAULT_PASS")
if defaultUser == "" {
defaultUser = "admin"
}
if defaultPass == "" {
if !cfg.Debug {
log.Fatal("CRITICAL: DEFAULT_PASS must be set in production. Set DEBUG=true for development.")
}
log.Println("WARNING: Using default password - set DEFAULT_PASS for production")
defaultPass = "changeme"
}
if err := authService.EnsureDefaultUser(defaultUser, defaultPass); err != nil {
log.Printf("Warning: failed to ensure default user: %v", err)
}
// Parse templates for auth handlers
authTemplates, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html"))
if err != nil {
log.Printf("Warning: failed to parse auth templates: %v", err)
}
// Initialize auth handlers
authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates)
// Initialize API clients
todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey)
trelloClient := api.NewTrelloClient(cfg.TrelloAPIKey, cfg.TrelloToken)
var planToEatClient api.PlanToEatAPI
if cfg.HasPlanToEat() {
planToEatClient = api.NewPlanToEatClient(cfg.PlanToEatAPIKey)
}
var googleCalendarClient api.GoogleCalendarAPI
if cfg.HasGoogleCalendar() {
var err error
googleCalendarClient, err = api.NewGoogleCalendarClient(context.Background(), cfg.GoogleCredentialsFile, cfg.GoogleCalendarID)
if err != nil {
log.Printf("Warning: failed to initialize Google Calendar client: %v", err)
} else {
log.Printf("Google Calendar client initialized for calendars: %s", cfg.GoogleCalendarID)
}
}
// Initialize handlers
h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, cfg)
tabsHandler := handlers.NewTabsHandler(db, googleCalendarClient, cfg.TemplateDir)
// Set up router
r := chi.NewRouter()
// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection
// Public routes (no auth required)
r.Get("/login", authHandlers.HandleLoginPage)
r.Post("/login", authHandlers.HandleLogin)
r.Post("/logout", authHandlers.HandleLogout)
// Serve static files (public)
fileServer := http.FileServer(http.Dir(cfg.StaticDir))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// Protected routes (auth required)
r.Group(func(r chi.Router) {
r.Use(authHandlers.Middleware().RequireAuth)
// Dashboard
r.Get("/", h.HandleDashboard)
r.Post("/api/refresh", h.HandleRefresh)
r.Get("/api/tasks", h.HandleGetTasks)
r.Get("/api/meals", h.HandleGetMeals)
r.Get("/api/boards", h.HandleGetBoards)
// Tab routes for HTMX
r.Get("/tabs/tasks", tabsHandler.HandleTasks)
r.Get("/tabs/planning", tabsHandler.HandlePlanning)
r.Get("/tabs/meals", tabsHandler.HandleMeals)
r.Post("/tabs/refresh", h.HandleRefreshTab)
// Trello card operations
r.Post("/cards", h.HandleCreateCard)
r.Post("/cards/complete", h.HandleCompleteCard)
// Todoist task operations
r.Post("/tasks", h.HandleCreateTask)
r.Post("/tasks/complete", h.HandleCompleteTask)
// Unified task completion (for Tasks tab Atoms)
r.Post("/complete-atom", h.HandleCompleteAtom)
r.Post("/uncomplete-atom", h.HandleUncompleteAtom)
// Unified Quick Add (for Tasks tab)
r.Post("/unified-add", h.HandleUnifiedAdd)
r.Get("/partials/lists", h.HandleGetListsOptions)
r.Get("/partials/shopping-lists", h.HandleGetShoppingLists)
// Task detail/edit
r.Get("/tasks/detail", h.HandleGetTaskDetail)
r.Post("/tasks/update", h.HandleUpdateTask)
// Bug reporting
r.Get("/bugs", h.HandleGetBugs)
r.Post("/bugs", h.HandleReportBug)
})
// Start server
addr := ":" + cfg.Port
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown
go func() {
log.Printf("Starting server on http://localhost%s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}