wannpassts/backend/internal/httpapi/public.go
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

201 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}