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
289
backend/internal/httpapi/auth.go
Normal file
289
backend/internal/httpapi/auth.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if !validEmail(in.Email) {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 8 || len(in.Password) > 200 {
|
||||
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8–200 Zeichen lang sein")
|
||||
return
|
||||
}
|
||||
if in.Name == "" || len(in.Name) > 80 {
|
||||
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
|
||||
return
|
||||
}
|
||||
tz := in.Timezone
|
||||
if tz == "" {
|
||||
tz = "Europe/Berlin"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||
return
|
||||
}
|
||||
if _, err := s.DB.UserByEmail(in.Email); err == nil {
|
||||
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
|
||||
return
|
||||
}
|
||||
slug, err := s.uniqueSlug()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||
return
|
||||
}
|
||||
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
|
||||
if err := s.DB.CreateUser(u); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
token, err := s.signJWT(u.ID, tokenTTL)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
|
||||
return
|
||||
}
|
||||
token, err := s.signJWT(u.ID, tokenTTL)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
|
||||
}
|
||||
|
||||
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r)
|
||||
var in 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"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if name == "" || len(name) > 80 {
|
||||
jsonError(w, http.StatusBadRequest, "Name muss 1–80 Zeichen haben")
|
||||
return
|
||||
}
|
||||
u.Name = name
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if _, err := time.LoadLocation(*in.Timezone); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
|
||||
return
|
||||
}
|
||||
u.Timezone = *in.Timezone
|
||||
}
|
||||
if in.SlotMinutes != nil {
|
||||
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
|
||||
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5–180 Minuten sein")
|
||||
return
|
||||
}
|
||||
u.SlotMinutes = *in.SlotMinutes
|
||||
}
|
||||
if in.DayStartMin != nil {
|
||||
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
|
||||
return
|
||||
}
|
||||
u.DayStartMin = *in.DayStartMin
|
||||
}
|
||||
if in.DayEndMin != nil {
|
||||
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
|
||||
return
|
||||
}
|
||||
u.DayEndMin = *in.DayEndMin
|
||||
}
|
||||
if u.DayStartMin >= u.DayEndMin {
|
||||
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
|
||||
return
|
||||
}
|
||||
if in.HorizonDays != nil {
|
||||
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
|
||||
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1–120 Tage sein")
|
||||
return
|
||||
}
|
||||
u.HorizonDays = *in.HorizonDays
|
||||
}
|
||||
if in.Durations != nil {
|
||||
durations, err := cleanDurations(in.Durations)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.Durations = intsCSV(durations)
|
||||
}
|
||||
if in.Weekdays != nil {
|
||||
weekdays, err := cleanWeekdays(in.Weekdays)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.Weekdays = intsCSV(weekdays)
|
||||
}
|
||||
if err := s.DB.UpdateUserSettings(u); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r)
|
||||
slug, err := s.uniqueSlug()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
|
||||
return
|
||||
}
|
||||
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
u.Slug = slug
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
|
||||
}
|
||||
|
||||
func (s *Server) uniqueSlug() (string, error) {
|
||||
for i := 0; i < 8; i++ {
|
||||
b := make([]byte, 9)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
slug := base64.RawURLEncoding.EncodeToString(b)
|
||||
exists, err := s.DB.SlugExists(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return slug, nil
|
||||
}
|
||||
}
|
||||
return "", http.ErrBodyNotAllowed
|
||||
}
|
||||
|
||||
func validEmail(s string) bool {
|
||||
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
|
||||
return false
|
||||
}
|
||||
at := strings.IndexByte(s, '@')
|
||||
local, domain := s[:at], s[at+1:]
|
||||
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(s, " \t")
|
||||
}
|
||||
|
||||
func cleanDurations(list []int) ([]int, error) {
|
||||
if len(list) == 0 || len(list) > 8 {
|
||||
return nil, errNew("1–8 Dauerangaben benötigt")
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, v := range list {
|
||||
if v < 5 || v > 480 {
|
||||
return nil, errNew("Dauern müssen 5–480 Minuten sein")
|
||||
}
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cleanWeekdays(list []int) ([]int, error) {
|
||||
if len(list) == 0 || len(list) > 7 {
|
||||
return nil, errNew("Mindestens ein Buchungstag nötig")
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, v := range list {
|
||||
if v < 1 || v > 7 {
|
||||
return nil, errNew("Wochentage müssen 1 (Mo) – 7 (So) sein")
|
||||
}
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func errNew(msg string) error { return &simpleError{msg} }
|
||||
|
||||
type simpleError struct{ msg string }
|
||||
|
||||
func (e *simpleError) Error() string { return e.msg }
|
||||
93
backend/internal/httpapi/bookings.go
Normal file
93
backend/internal/httpapi/bookings.go
Normal 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))
|
||||
}
|
||||
247
backend/internal/httpapi/calendars.go
Normal file
247
backend/internal/httpapi/calendars.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/crypto"
|
||||
)
|
||||
|
||||
func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
conns, err := s.DB.ConnectionsForUser(userFrom(r).ID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar")
|
||||
return
|
||||
}
|
||||
out := make([]ConnectionJSON, 0, len(conns))
|
||||
for _, c := range conns {
|
||||
out = append(out, connToJSON(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
var in struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/")
|
||||
if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") {
|
||||
jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen")
|
||||
return
|
||||
}
|
||||
if in.Username == "" || in.Password == "" {
|
||||
jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := calendar.CalDAVConnConfig{
|
||||
ServerURL: in.ServerURL,
|
||||
Username: in.Username,
|
||||
Password: in.Password,
|
||||
CalendarPath: strings.TrimSpace(in.CalendarPath),
|
||||
}
|
||||
if cfg.CalendarPath == "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
path, name, err := calendar.DiscoverCalDAV(ctx, cfg)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.CalendarPath = path
|
||||
if in.DisplayName == "" {
|
||||
in.DisplayName = name
|
||||
}
|
||||
}
|
||||
if in.DisplayName == "" {
|
||||
if u, err := url.Parse(in.ServerURL); err == nil {
|
||||
in.DisplayName = u.Host
|
||||
} else {
|
||||
in.DisplayName = "CalDAV-Kalender"
|
||||
}
|
||||
}
|
||||
|
||||
enc, err := s.encryptConfig(cfg)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||
}
|
||||
|
||||
func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
var in struct {
|
||||
URL string `json:"url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
|
||||
return
|
||||
}
|
||||
rawURL := strings.TrimSpace(in.URL)
|
||||
if strings.HasPrefix(rawURL, "webcal://") {
|
||||
rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://")
|
||||
}
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen")
|
||||
return
|
||||
}
|
||||
|
||||
// Einmal direkt abrufen, um den Link zu validieren.
|
||||
prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if in.DisplayName == "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
in.DisplayName = u.Host
|
||||
} else {
|
||||
in.DisplayName = "ICS-Kalender"
|
||||
}
|
||||
}
|
||||
enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL})
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
|
||||
}
|
||||
|
||||
func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" {
|
||||
jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)")
|
||||
return
|
||||
}
|
||||
state, err := s.signJWT(userFrom(r).ID, 10*time.Minute)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden")
|
||||
return
|
||||
}
|
||||
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||
authURL := conf.AuthCodeURL(state,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.SetAuthURLParam("prompt", "consent select_account"),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": authURL})
|
||||
}
|
||||
|
||||
func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) {
|
||||
fail := func(reason string) {
|
||||
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if reason := q.Get("error"); reason != "" {
|
||||
fail("Google-Anmeldung abgelehnt: " + reason)
|
||||
return
|
||||
}
|
||||
uid, err := s.verifyJWT(q.Get("state"))
|
||||
if err != nil {
|
||||
fail("Ungültiger or abgelaufener Verbindungsversuch – bitte erneut versuchen")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.UserByID(uid)
|
||||
if err != nil {
|
||||
fail("Konto nicht gefunden")
|
||||
return
|
||||
}
|
||||
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.AppURL+"/api/calendars/google/callback")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
tok, err := conf.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error()))
|
||||
return
|
||||
}
|
||||
calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok))
|
||||
if err != nil {
|
||||
fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error()))
|
||||
return
|
||||
}
|
||||
name := summary
|
||||
if name == "" {
|
||||
name = "Google Kalender"
|
||||
}
|
||||
enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID})
|
||||
if err != nil {
|
||||
fail("Speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil {
|
||||
fail("Verbindung konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.Syncer.SyncInBackground(user)
|
||||
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
|
||||
defer cancel()
|
||||
s.Syncer.SyncUser(ctx, user)
|
||||
s.ListCalendars(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) encryptConfig(cfg any) (string, error) {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return crypto.Encrypt(s.Cfg.EncryptionKey, data)
|
||||
}
|
||||
|
||||
func cleanReason(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
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
|
||||
}
|
||||
280
backend/internal/httpapi/server.go
Normal file
280
backend/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/config"
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
DB *db.DB
|
||||
Cfg *config.Config
|
||||
Syncer *calendar.Syncer
|
||||
}
|
||||
|
||||
func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server {
|
||||
return &Server{DB: database, Cfg: cfg, Syncer: syncer}
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxUser ctxKey = 1
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL),
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/register", s.Register)
|
||||
api.Post("/auth/login", s.Login)
|
||||
|
||||
api.Get("/calendars/google/callback", s.GoogleConnectCallback)
|
||||
api.Get("/public/{slug}", s.PublicInfo)
|
||||
api.Post("/public/{slug}/bookings", s.CreateBooking)
|
||||
|
||||
api.Group(func(prt chi.Router) {
|
||||
prt.Use(s.AuthMiddleware)
|
||||
prt.Get("/me", s.Me)
|
||||
prt.Patch("/me", s.UpdateMe)
|
||||
prt.Post("/me/slug", s.RegenSlug)
|
||||
prt.Get("/calendars", s.ListCalendars)
|
||||
prt.Delete("/calendars/{id}", s.DeleteCalendar)
|
||||
prt.Post("/calendars/caldav", s.ConnectCalDAV)
|
||||
prt.Post("/calendars/ics", s.ConnectICS)
|
||||
prt.Get("/calendars/google/start", s.GoogleConnectStart)
|
||||
prt.Post("/calendars/sync", s.SyncNow)
|
||||
prt.Get("/bookings", s.ListBookings)
|
||||
prt.Post("/bookings/{id}/accept", s.AcceptBooking)
|
||||
prt.Post("/bookings/{id}/decline", s.DeclineBooking)
|
||||
})
|
||||
})
|
||||
|
||||
if s.Cfg.StaticDir != "" {
|
||||
if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() {
|
||||
static := s.Cfg.StaticDir
|
||||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
p := filepath.Join(static, filepath.Clean(req.URL.Path))
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
http.ServeFile(w, req, p)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, filepath.Join(static, "index.html"))
|
||||
})
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func allowedOrigins(frontendURL string) []string {
|
||||
origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||
for _, o := range strings.Split(frontendURL, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" && !contains(origins, o) {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Auth Middleware & JWT ---
|
||||
|
||||
func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatInt(userID, 10),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(s.Cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *Server) verifyJWT(tokenStr string) (int64, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unerwartete Signaturmethode")
|
||||
}
|
||||
return []byte(s.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, errors.New("Token ungültig oder abgelaufen")
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return 0, errors.New("Claims unlesbar")
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("Subject unlesbar")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
jsonError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):]))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.UserByID(uid)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
|
||||
})
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) *db.User {
|
||||
return r.Context().Value(ctxUser).(*db.User)
|
||||
}
|
||||
|
||||
// --- JSON helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request, v any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
}
|
||||
|
||||
// --- JSON Darstellungen ---
|
||||
|
||||
type UserJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
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"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func userToJSON(u *db.User) UserJSON {
|
||||
return UserJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone,
|
||||
SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin,
|
||||
HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays),
|
||||
CreatedTs: u.CreatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func csvInts(csv string) []int {
|
||||
var out []int
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intsCSV(list []int) string {
|
||||
parts := make([]string, len(list))
|
||||
for i, v := range list {
|
||||
parts[i] = strconv.Itoa(v)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type ConnectionJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastSyncedTs *int64 `json:"last_synced_ts"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func connToJSON(c *db.Connection) ConnectionJSON {
|
||||
out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs}
|
||||
if c.LastSyncedTs.Valid {
|
||||
v := c.LastSyncedTs.Int64
|
||||
out.LastSyncedTs = &v
|
||||
}
|
||||
if c.LastError.Valid && c.LastError.String != "" {
|
||||
v := c.LastError.String
|
||||
out.LastError = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type BookingJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
RequesterEmail string `json:"requester_email"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func bookingToJSON(b *db.Booking) BookingJSON {
|
||||
return BookingJSON{
|
||||
ID: b.ID,
|
||||
Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339),
|
||||
End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339),
|
||||
RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail,
|
||||
Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue