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,93 @@
package httpapi
import (
"net/http"
)
func (s *Server) ListBookings(w http.ResponseWriter, r *http.Request) {
bookings, err := s.DB.BookingsForUser(userFrom(r).ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Anfragen nicht lesbar")
return
}
out := make([]BookingJSON, 0, len(bookings))
for _, b := range bookings {
out = append(out, bookingToJSON(b))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) AcceptBooking(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
booking, err := s.DB.BookingByID(id, user.ID)
if err != nil {
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
return
}
if booking.Status != "pending" {
jsonError(w, http.StatusConflict, "Diese Anfrage wurde bereits bearbeitet")
return
}
// Kollision mit bestehenden busy-Zeiträumen prüfen (angommene Buchungen
// und Kalendertermine liegen beide in busy_slots).
busy, err := s.DB.BusyForUser(user.ID, booking.StartTs-1, booking.EndTs+1)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
return
}
for _, b := range busy {
if b.BookingID.Valid && b.BookingID.Int64 == booking.ID {
continue
}
if b.StartTs < booking.EndTs && booking.StartTs < b.EndTs {
jsonError(w, http.StatusConflict, "Der Zeitraum kollidiert inzwischen mit einem anderen Termin")
return
}
}
if err := s.DB.InsertBusyForBooking(booking); err != nil {
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht eingetragen werden")
return
}
if err := s.DB.SetBookingStatus(booking.ID, "accepted"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
booking.Status = "accepted"
writeJSON(w, http.StatusOK, bookingToJSON(booking))
}
func (s *Server) DeclineBooking(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
booking, err := s.DB.BookingByID(id, user.ID)
if err != nil {
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
return
}
if booking.Status == "pending" {
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
} else if booking.Status == "accepted" {
if err := s.DB.DeleteBusyByBooking(booking.ID); err != nil {
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht entfernt werden")
return
}
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
}
booking.Status = "declined"
writeJSON(w, http.StatusOK, bookingToJSON(booking))
}