diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 0757d34..9cbb022 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -252,6 +252,12 @@ func main() { api.GET("/calendar/holidays", handler.ListCalendarHolidays) api.GET("/calendar/config", handler.GetCalendarConfig) api.PUT("/calendar/config", handler.UpsertCalendarConfig) + + // Phase 9b: school-events CRUD + Schuljahres-Rollover. + api.GET("/calendar/events", handler.ListSchoolEvents) + api.POST("/calendar/events", handler.CreateSchoolEvent) + api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent) + api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear) } // Start server diff --git a/school-service/internal/handlers/calendar_handlers.go b/school-service/internal/handlers/calendar_handlers.go index 719478f..f7928c7 100644 --- a/school-service/internal/handlers/calendar_handlers.go +++ b/school-service/internal/handlers/calendar_handlers.go @@ -74,3 +74,72 @@ func (h *Handler) UpsertCalendarConfig(c *gin.Context) { } respondCreated(c, cfg) } + +// ---------- School Events (Phase 9b) ---------- + +func (h *Handler) CreateSchoolEvent(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSchoolEventRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + ev, err := h.calendarService.CreateEvent(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create event: "+err.Error()) + return + } + respondCreated(c, ev) +} + +func (h *Handler) ListSchoolEvents(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + events, err := h.calendarService.ListEvents(c.Request.Context(), uid, c.Query("from"), c.Query("to")) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list events: "+err.Error()) + return + } + if events == nil { + events = []models.SchoolEvent{} + } + respondSuccess(c, events) +} + +func (h *Handler) DeleteSchoolEvent(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.calendarService.DeleteEvent(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete event: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Event deleted"}) +} + +func (h *Handler) RolloverSchoolYear(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.SchoolYearRolloverRequest + // Body is optional — empty defaults to next-Aug rollover. + _ = c.ShouldBindJSON(&req) + + result, err := h.calendarService.RolloverSchoolYear(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Rollover failed: "+err.Error()) + return + } + respondSuccess(c, result) +} diff --git a/school-service/internal/models/calendar.go b/school-service/internal/models/calendar.go index 3c0aa91..6abf42b 100644 --- a/school-service/internal/models/calendar.go +++ b/school-service/internal/models/calendar.go @@ -78,3 +78,20 @@ type CreateSchoolEventRequest struct { NotifyStudents bool `json:"notify_students"` NotificationLeadDays []int `json:"notification_lead_days"` } + +// SchoolYearRolloverRequest moves all classes up by one grade and updates +// the config's school-year dates. Optional date pair, otherwise defaults +// to next Aug 01 → following Jul 31. +type SchoolYearRolloverRequest struct { + NewYearStart *string `json:"new_year_start,omitempty"` // YYYY-MM-DD + NewYearEnd *string `json:"new_year_end,omitempty"` +} + +// SchoolYearRolloverResult is what the endpoint returns so the UI can show +// "promoted 8 classes, removed 2 graduating ones". +type SchoolYearRolloverResult struct { + ClassesPromoted int `json:"classes_promoted"` + ClassesGraduated int `json:"classes_graduated"` + NewYearStart string `json:"new_year_start"` + NewYearEnd string `json:"new_year_end"` +} diff --git a/school-service/internal/services/calendar_events.go b/school-service/internal/services/calendar_events.go new file mode 100644 index 0000000..7b5fbd0 --- /dev/null +++ b/school-service/internal/services/calendar_events.go @@ -0,0 +1,134 @@ +package services + +import ( + "context" + "fmt" + + "github.com/breakpilot/school-service/internal/models" + "github.com/google/uuid" +) + +// School-event CRUD. Ownership is per-user via created_by_user_id. Public +// holiday/Ferien data lives in cal_public_event and is handled by +// calendar_service.go. + +func (s *CalendarService) CreateEvent(ctx context.Context, userID string, req *models.CreateSchoolEventRequest) (*models.SchoolEvent, error) { + classIDs, err := parseClassIDs(req.AffectedClassIDs) + if err != nil { + return nil, err + } + + var e models.SchoolEvent + leadDays := req.NotificationLeadDays + if leadDays == nil { + leadDays = []int{7, 1} + } + + row := s.db.QueryRow(ctx, ` + INSERT INTO cal_school_event + (created_by_user_id, title, description, event_type, is_school_free, + start_date, end_date, start_time, end_time, affected_class_ids, + visible_to_parents, notify_parents, notify_students, notification_lead_days) + VALUES ($1, $2, $3, $4, $5, + $6::date, $7::date, NULLIF($8, '')::time, NULLIF($9, '')::time, + $10, $11, $12, $13, $14) + RETURNING id, created_by_user_id, title, COALESCE(description,''), event_type, + is_school_free, start_date::text, end_date::text, + start_time::text, end_time::text, affected_class_ids, + visible_to_parents, notify_parents, notify_students, + notification_lead_days, created_at, updated_at + `, userID, req.Title, req.Description, req.EventType, req.IsSchoolFree, + req.StartDate, req.EndDate, strOrEmpty(req.StartTime), strOrEmpty(req.EndTime), + classIDs, req.VisibleToParents, req.NotifyParents, req.NotifyStudents, leadDays) + + if err := scanEvent(row, &e); err != nil { + return nil, err + } + return &e, nil +} + +func (s *CalendarService) ListEvents(ctx context.Context, userID, from, to string) ([]models.SchoolEvent, error) { + if from == "" { + from = "1900-01-01" + } + if to == "" { + to = "2100-12-31" + } + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, title, COALESCE(description,''), event_type, + is_school_free, start_date::text, end_date::text, + start_time::text, end_time::text, affected_class_ids, + visible_to_parents, notify_parents, notify_students, + notification_lead_days, created_at, updated_at + FROM cal_school_event + WHERE created_by_user_id = $1 + AND end_date >= $2::date + AND start_date <= $3::date + ORDER BY start_date, start_time NULLS FIRST, title + `, userID, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SchoolEvent + for rows.Next() { + var e models.SchoolEvent + if err := scanEvent(rows, &e); err != nil { + return nil, err + } + out = append(out, e) + } + return out, nil +} + +func (s *CalendarService) DeleteEvent(ctx context.Context, id, userID string) error { + res, err := s.db.Exec(ctx, ` + DELETE FROM cal_school_event WHERE id = $1 AND created_by_user_id = $2 + `, id, userID) + if err != nil { + return err + } + if res.RowsAffected() == 0 { + return fmt.Errorf("event not found or not owned") + } + return nil +} + +// parseClassIDs validates the array of UUID strings the request sent. +// Returns a typed []uuid.UUID so asyncpg/pgx encodes it correctly into the +// UUID[] column. +func parseClassIDs(in []string) ([]uuid.UUID, error) { + out := make([]uuid.UUID, 0, len(in)) + for _, s := range in { + if s == "" { + continue + } + u, err := uuid.Parse(s) + if err != nil { + return nil, fmt.Errorf("invalid class_id %q: %w", s, err) + } + out = append(out, u) + } + return out, nil +} + +// row interface so the same scan logic works for both QueryRow and Rows. +type rowScanner interface { + Scan(dest ...any) error +} + +func scanEvent(r rowScanner, e *models.SchoolEvent) error { + var startTime, endTime *string + if err := r.Scan( + &e.ID, &e.CreatedByUserID, &e.Title, &e.Description, &e.EventType, + &e.IsSchoolFree, &e.StartDate, &e.EndDate, + &startTime, &endTime, &e.AffectedClassIDs, + &e.VisibleToParents, &e.NotifyParents, &e.NotifyStudents, + &e.NotificationLeadDays, &e.CreatedAt, &e.UpdatedAt, + ); err != nil { + return err + } + e.StartTime = startTime + e.EndTime = endTime + return nil +} diff --git a/school-service/internal/services/calendar_rollover.go b/school-service/internal/services/calendar_rollover.go new file mode 100644 index 0000000..32b1ec0 --- /dev/null +++ b/school-service/internal/services/calendar_rollover.go @@ -0,0 +1,85 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/school-service/internal/models" +) + +// RolloverSchoolYear advances every tt_class for this user by one grade +// level, removes graduating classes (grade > 13), and updates the +// cal_school_config school-year dates. Operates as a single transaction. +// +// Stammdaten (teachers, subjects, rooms, periods) bleiben unveraendert — +// es aendern sich nur Klassen-Stufen. +func (s *CalendarService) RolloverSchoolYear(ctx context.Context, userID string, req *models.SchoolYearRolloverRequest) (*models.SchoolYearRolloverResult, error) { + newStart, newEnd := defaultSchoolYearDates(req) + + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // 1. Remove the graduating cohort first so they don't get bumped to 14. + gradRes, err := tx.Exec(ctx, ` + DELETE FROM tt_class WHERE created_by_user_id = $1 AND grade_level >= 13 + `, userID) + if err != nil { + return nil, fmt.Errorf("delete graduating: %w", err) + } + + // 2. Promote everyone else. + promRes, err := tx.Exec(ctx, ` + UPDATE tt_class SET grade_level = grade_level + 1 + WHERE created_by_user_id = $1 + `, userID) + if err != nil { + return nil, fmt.Errorf("promote classes: %w", err) + } + + // 3. Update the school-year dates in the config (creates a row if the + // user never picked a Bundesland — but that's an edge case; in normal + // flow the wizard has run before rollover). + _, err = tx.Exec(ctx, ` + UPDATE cal_school_config + SET school_year_start = $1::date, + school_year_end = $2::date, + updated_at = NOW() + WHERE user_id = $3 + `, newStart, newEnd, userID) + if err != nil { + return nil, fmt.Errorf("update config: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &models.SchoolYearRolloverResult{ + ClassesPromoted: int(promRes.RowsAffected()), + ClassesGraduated: int(gradRes.RowsAffected()), + NewYearStart: newStart, + NewYearEnd: newEnd, + }, nil +} + +// defaultSchoolYearDates returns the dates from the request if both set, +// otherwise the next school year starting Aug 1 of "this year or next" +// and ending Jul 31 the year after. +func defaultSchoolYearDates(req *models.SchoolYearRolloverRequest) (string, string) { + if req != nil && req.NewYearStart != nil && req.NewYearEnd != nil { + return *req.NewYearStart, *req.NewYearEnd + } + now := time.Now() + startYear := now.Year() + // If we're past August, the "new" year refers to the next calendar year. + if int(now.Month()) >= 8 { + startYear++ + } + start := time.Date(startYear, 8, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(startYear+1, 7, 31, 0, 0, 0, 0, time.UTC) + return start.Format("2006-01-02"), end.Format("2006-01-02") +} diff --git a/school-service/internal/services/calendar_service_test.go b/school-service/internal/services/calendar_service_test.go index 2a4186c..b8e4f2d 100644 --- a/school-service/internal/services/calendar_service_test.go +++ b/school-service/internal/services/calendar_service_test.go @@ -62,3 +62,41 @@ func TestNewCalendarService_Constructs(t *testing.T) { t.Fatal("expected non-nil service") } } + +func TestDefaultSchoolYearDates_FallbackFormat(t *testing.T) { + // No override → deterministic YYYY-MM-DD strings with end > start. + start, end := defaultSchoolYearDates(nil) + if len(start) != 10 || len(end) != 10 { + t.Fatalf("expected YYYY-MM-DD strings, got %q %q", start, end) + } + if end <= start { + t.Errorf("end %q must be after start %q", end, start) + } +} + +func TestDefaultSchoolYearDates_ExplicitOverride(t *testing.T) { + s, e := "2030-09-01", "2031-06-30" + req := &models.SchoolYearRolloverRequest{NewYearStart: &s, NewYearEnd: &e} + gotS, gotE := defaultSchoolYearDates(req) + if gotS != s || gotE != e { + t.Errorf("override ignored: got %q/%q want %q/%q", gotS, gotE, s, e) + } +} + +func TestParseClassIDs_AcceptsValidAndRejectsGarbage(t *testing.T) { + good := []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"} + out, err := parseClassIDs(good) + if err != nil || len(out) != 2 { + t.Fatalf("expected 2 parsed UUIDs, got %v err=%v", out, err) + } + + if _, err := parseClassIDs([]string{"not-a-uuid"}); err == nil { + t.Errorf("expected error for invalid uuid") + } + + // Empty strings are silently dropped (curl convenience). + out, err = parseClassIDs([]string{"", "00000000-0000-0000-0000-000000000003", ""}) + if err != nil || len(out) != 1 { + t.Errorf("expected 1 parsed UUID, got %v err=%v", out, err) + } +} diff --git a/studio-v2/app/schulkalender/_components/DayDetail.tsx b/studio-v2/app/schulkalender/_components/DayDetail.tsx new file mode 100644 index 0000000..365430f --- /dev/null +++ b/studio-v2/app/schulkalender/_components/DayDetail.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useTheme } from '@/lib/ThemeContext' +import { calendarApi } from '@/lib/schulkalender/api' +import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types' +import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types' + +interface DayDetailProps { + iso: string + holidays: PublicEvent[] + events: SchoolEvent[] + onClose: () => void + onDeleted: () => void +} + +export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) { + const { isDark } = useTheme() + + const handleDelete = async (id: string) => { + if (!confirm('Termin wirklich loeschen?')) return + try { + await calendarApi.deleteEvent(id) + onDeleted() + } catch { + // best-effort + } + } + + const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900' + + const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date) + const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date) + + const formattedDate = new Date(iso).toLocaleDateString('de-DE', { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + }) + + return ( +
+
+
+

{formattedDate}

+ +
+ + {dayHolidays.length === 0 && dayEvents.length === 0 && ( +

+ Keine Eintraege fuer diesen Tag. +

+ )} + + {dayHolidays.length > 0 && ( +
+

Bundesweite Eintraege

+ {dayHolidays.map(h => ( +
+
{h.name_de}
+
{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` – ${h.end_date}` : ''}
+
+ ))} +
+ )} + + {dayEvents.length > 0 && ( +
+

Schul-Termine

+ {dayEvents.map(e => ( +
+
+
+
{e.title}
+
+ {EVENT_TYPE_LABEL[e.event_type]} + {e.start_time && ` · ${e.start_time}${e.end_time ? `–${e.end_time}` : ''}`} + {e.is_school_free && ' · unterrichtsfrei'} +
+ {e.description &&
{e.description}
} +
+ {e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'} + {e.notify_parents && ' · 📧 Eltern erinnern'} + {e.notify_students && ' · 💬 Schueler erinnern'} +
+
+ +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/studio-v2/app/schulkalender/_components/EventModal.tsx b/studio-v2/app/schulkalender/_components/EventModal.tsx new file mode 100644 index 0000000..6d1f222 --- /dev/null +++ b/studio-v2/app/schulkalender/_components/EventModal.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { calendarApi } from '@/lib/schulkalender/api' +import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types' +import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types' + +interface EventModalProps { + defaultDate: string // YYYY-MM-DD + onClose: () => void + onCreated: () => void +} + +const initial = (date: string): CreateSchoolEvent => ({ + title: '', + event_type: 'fortbildung', + is_school_free: false, + start_date: date, + end_date: date, + visible_to_parents: true, + notify_parents: false, + notify_students: false, + notification_lead_days: [7, 1], +}) + +export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) { + const { isDark } = useTheme() + const [form, setForm] = useState(initial(defaultDate)) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setError(null) + try { + await calendarApi.createEvent(form) + onCreated() + } catch (err) { + setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen') + } finally { + setSaving(false) + } + } + + const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900' + const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' + + return ( +
+
+
+

Neuer Termin

+ +
+ +
+ + setForm({ ...form, title: e.target.value })} + placeholder="z.B. SCHILF: Digitale Tafeln" + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + data-testid="event-title" + /> +
+ +
+
+ + +
+
+ setForm({ ...form, is_school_free: e.target.checked })} + className="w-5 h-5" + /> + +
+
+ +
+
+ + setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+ +