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:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
201
backend/internal/httpapi/public.go
Normal file
201
backend/internal/httpapi/public.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue