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
This commit is contained in:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View file

@ -0,0 +1,201 @@
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
}