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
201 lines
5.9 KiB
Go
201 lines
5.9 KiB
Go
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
|
||
}
|