package services import ( "context" "encoding/json" "fmt" "log" "os" "github.com/breakpilot/school-service/internal/models" "github.com/jackc/pgx/v5/pgxpool" ) // CalendarService owns the cal_* tables and the read of the seed snapshot // on first boot. Holidays are global (no owner) — same data for every // school in a given Bundesland. type CalendarService struct { db *pgxpool.Pool } func NewCalendarService(db *pgxpool.Pool) *CalendarService { return &CalendarService{db: db} } // SeedFromSnapshot reads internal/seed/calendar_holidays.json and bulk-inserts // every row that doesn't already exist (idempotent via the unique constraint // on region+event_type+name_de+start_date). Called once at server start. func (s *CalendarService) SeedFromSnapshot(ctx context.Context, path string) error { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { log.Printf("calendar seed file not found at %s — skipping", path) return nil } return fmt.Errorf("read snapshot: %w", err) } var events []models.PublicEvent if err := json.Unmarshal(raw, &events); err != nil { return fmt.Errorf("parse snapshot: %w", err) } tx, err := s.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) inserted := 0 for _, e := range events { ct, err := tx.Exec(ctx, ` INSERT INTO cal_public_event (region, event_type, name_de, name_en, start_date, end_date, source) VALUES ($1, $2, $3, NULLIF($4, ''), $5::date, $6::date, 'OpenHolidaysAPI') ON CONFLICT (region, event_type, name_de, start_date) DO NOTHING `, e.Region, e.EventType, e.NameDe, e.NameEn, e.StartDate, e.EndDate) if err != nil { return fmt.Errorf("insert event: %w", err) } inserted += int(ct.RowsAffected()) } if err := tx.Commit(ctx); err != nil { return err } log.Printf("calendar seed: %d new events inserted (of %d in snapshot)", inserted, len(events)) return nil } // ListHolidays returns all public + school holidays for the given region // between from..to (YYYY-MM-DD inclusive). func (s *CalendarService) ListHolidays(ctx context.Context, region, from, to string) ([]models.PublicEvent, error) { rows, err := s.db.Query(ctx, ` SELECT id, region, event_type, name_de, COALESCE(name_en, ''), start_date::text, end_date::text, COALESCE(source, ''), created_at FROM cal_public_event WHERE region = $1 AND end_date >= $2::date AND start_date <= $3::date ORDER BY start_date, event_type, name_de `, region, from, to) if err != nil { return nil, err } defer rows.Close() var out []models.PublicEvent for rows.Next() { var e models.PublicEvent if err := rows.Scan(&e.ID, &e.Region, &e.EventType, &e.NameDe, &e.NameEn, &e.StartDate, &e.EndDate, &e.Source, &e.CreatedAt); err != nil { return nil, err } out = append(out, e) } return out, nil } // GetConfig returns the per-user calendar config (Bundesland etc.) or nil // if the user has not configured one yet. func (s *CalendarService) GetConfig(ctx context.Context, userID string) (*models.SchoolCalendarConfig, error) { var c models.SchoolCalendarConfig err := s.db.QueryRow(ctx, ` SELECT user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at FROM cal_school_config WHERE user_id = $1 `, userID).Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt) if err != nil { // pgx returns no-rows error; caller maps to 404. return nil, err } return &c, nil } // UpsertConfig inserts or updates the Bundesland selection. func (s *CalendarService) UpsertConfig(ctx context.Context, userID string, req *models.UpsertSchoolCalendarConfigRequest) (*models.SchoolCalendarConfig, error) { var c models.SchoolCalendarConfig err := s.db.QueryRow(ctx, ` INSERT INTO cal_school_config (user_id, bundesland, school_year_start, school_year_end) VALUES ($1, $2, NULLIF($3, '')::date, NULLIF($4, '')::date) ON CONFLICT (user_id) DO UPDATE SET bundesland = EXCLUDED.bundesland, school_year_start = EXCLUDED.school_year_start, school_year_end = EXCLUDED.school_year_end, updated_at = NOW() RETURNING user_id, bundesland, school_year_start::text, school_year_end::text, created_at, updated_at `, userID, req.Bundesland, strOrEmpty(req.SchoolYearStart), strOrEmpty(req.SchoolYearEnd)). Scan(&c.UserID, &c.Bundesland, &c.SchoolYearStart, &c.SchoolYearEnd, &c.CreatedAt, &c.UpdatedAt) return &c, err } func strOrEmpty(s *string) string { if s == nil { return "" } return *s }