package httpapi import ( "context" "net/http" "regexp" "strings" "time" "github.com/go-chi/chi/v5" "wannpassts/internal/calendar" "wannpassts/internal/db" ) var emailRX = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) type publicInterval struct { Start string `json:"start"` End string `json:"end"` } type publicInfo struct { Name string `json:"name"` Timezone string `json:"timezone"` SlotMinutes int `json:"slot_minutes"` DayStartMin int `json:"day_start_min"` DayEndMin int `json:"day_end_min"` HorizonDays int `json:"horizon_days"` Durations []int `json:"durations"` Weekdays []int `json:"weekdays"` Busy []publicInterval `json:"busy"` } // PublicInfo liefert die Buchungsseite: Name, Buchungsregeln und ausschließlich // busy-Zeiträume – niemals Termintitel, Beschreibungen oder Teilnehmer. func (s *Server) PublicInfo(w http.ResponseWriter, r *http.Request) { user, err := s.DB.UserBySlug(chi.URLParam(r, "slug")) if err != nil { jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden") return } now := time.Now() if conns, err := s.DB.ConnectionsForUser(user.ID); err == nil && calendar.NeedsSync(conns, now) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) s.Syncer.SyncUser(ctx, user) cancel() } horizonEnd := now.Add(time.Duration(user.HorizonDays+1) * 24 * time.Hour) busy, err := s.DB.BusyForUser(user.ID, now.Add(-time.Hour).Unix(), horizonEnd.Unix()) if err != nil { jsonError(w, http.StatusInternalServerError, "Belegung nicht lesbar") return } resp := publicInfo{ Name: user.Name, Timezone: user.Timezone, SlotMinutes: user.SlotMinutes, DayStartMin: user.DayStartMin, DayEndMin: user.DayEndMin, HorizonDays: user.HorizonDays, Durations: csvInts(user.Durations), Weekdays: csvInts(user.Weekdays), Busy: make([]publicInterval, 0, len(busy)), } for _, iv := range mergeBusy(busy) { resp.Busy = append(resp.Busy, publicInterval{ Start: time.Unix(iv.StartTs, 0).UTC().Format(time.RFC3339), End: time.Unix(iv.EndTs, 0).UTC().Format(time.RFC3339), }) } writeJSON(w, http.StatusOK, resp) } func mergeBusy(in []db.BusySlot) []db.BusySlot { var out []db.BusySlot for _, s := range in { if n := len(out); n > 0 && s.StartTs <= out[n-1].EndTs { if s.EndTs > out[n-1].EndTs { out[n-1].EndTs = s.EndTs } continue } out = append(out, s) } return out } // CreateBooking nimmt eine Buchungsanfrage an: Validiert gegen Buchungsregeln // und Belegung, legt die Anfrage mit Status "pending" an. func (s *Server) CreateBooking(w http.ResponseWriter, r *http.Request) { user, err := s.DB.UserBySlug(chi.URLParam(r, "slug")) if err != nil { jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden") return } var in struct { Start string `json:"start"` DurationMinutes int `json:"duration_minutes"` Name string `json:"name"` Email string `json:"email"` Message string `json:"message"` } if err := decodeBody(r, &in); err != nil { jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") return } in.Name = strings.TrimSpace(in.Name) in.Email = strings.TrimSpace(in.Email) in.Message = strings.TrimSpace(in.Message) if in.Name == "" || len(in.Name) > 100 { jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 100 Zeichen) angeben") return } if !emailRX.MatchString(in.Email) || len(in.Email) > 254 { jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben") return } if len(in.Message) > 2000 { jsonError(w, http.StatusBadRequest, "Nachricht zu lang (max. 2000 Zeichen)") return } allowed := false for _, d := range csvInts(user.Durations) { if d == in.DurationMinutes { allowed = true break } } if !allowed { jsonError(w, http.StatusBadRequest, "Ungültige Dauer") return } start, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Start)) if err != nil { jsonError(w, http.StatusBadRequest, "Ungültiger Startzeitpunkt") return } end := start.Add(time.Duration(in.DurationMinutes) * time.Minute) now := time.Now() if start.Unix() < now.Unix()-120 { jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt in der Vergangenheit") return } if start.After(now.Add(time.Duration(user.HorizonDays) * 24 * time.Hour)) { jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt außerhalb des Buchungszeitraums") return } loc, _ := time.LoadLocation(user.Timezone) if loc == nil { loc = time.UTC } local := start.In(loc) isoWeekday := (int(local.Weekday())+6)%7 + 1 // Mo=1 … So=7 if !containsInt(csvInts(user.Weekdays), isoWeekday) { jsonError(w, http.StatusBadRequest, "An diesem Tag werden keine Buchungen angenommen") return } minuteOfDay := local.Hour()*60 + local.Minute() if minuteOfDay < user.DayStartMin-2 || minuteOfDay+in.DurationMinutes > user.DayEndMin+2 { jsonError(w, http.StatusBadRequest, "Außerhalb der Buchungszeiten") return } busy, err := s.DB.BusyForUser(user.ID, start.Unix()-1, end.Unix()+1) if err != nil { jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar") return } for _, b := range busy { if b.StartTs < end.Unix() && start.Unix() < b.EndTs { jsonError(w, http.StatusConflict, "Dieser Zeitraum ist leider bereits belegt") return } } booking := &db.Booking{ UserID: user.ID, StartTs: start.Unix(), EndTs: end.Unix(), RequesterName: in.Name, RequesterEmail: in.Email, Message: in.Message, } if err := s.DB.CreateBooking(booking); err != nil { jsonError(w, http.StatusInternalServerError, "Anfrage konnte nicht gespeichert werden") return } booking.Status = "pending" writeJSON(w, http.StatusCreated, map[string]any{ "id": booking.ID, "status": booking.Status, }) } func containsInt(list []int, v int) bool { for _, x := range list { if x == v { return true } } return false }