Files
RentalManager/internal/calendar/calendar.go
2026-04-11 12:12:07 +02:00

208 lines
6.2 KiB
Go

package calendar
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Models ────────────────────────────────────────────────────────────────────
type Event struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
Title string `json:"title"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Source string `json:"source"` // airbnb | manual
IcalUID string `json:"ical_uid,omitempty"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
// Joint
PropertyName string `json:"property_name,omitempty"`
}
type OccupancyStats struct {
PropertyID string `json:"property_id"`
PropertyName string `json:"property_name"`
Month string `json:"month"`
TotalDays int `json:"total_days"`
OccupiedDays int `json:"occupied_days"`
OccupancyRate float64 `json:"occupancy_rate"`
TotalRevenue float64 `json:"total_revenue"`
AvgNightlyRate float64 `json:"avg_nightly_rate"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) List(propertyID, from, to string) ([]Event, error) {
query := `
SELECT e.id, e.property_id, COALESCE(e.title,''),
substr(e.start_date,1,10) as start_date,
substr(e.end_date,1,10) as end_date,
e.source, COALESCE(e.ical_uid,''), COALESCE(e.notes,''), e.created_at, p.name
FROM calendar_events e
JOIN properties p ON p.id = e.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND e.property_id=?"
args = append(args, propertyID)
}
if from != "" {
query += " AND e.end_date >= ?"
args = append(args, from)
}
if to != "" {
query += " AND e.start_date <= ?"
args = append(args, to)
}
query += " ORDER BY e.start_date"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.PropertyID, &e.Title, &e.StartDate, &e.EndDate,
&e.Source, &e.IcalUID, &e.Notes, &e.CreatedAt, &e.PropertyName); err != nil {
return nil, err
}
events = append(events, e)
}
return events, nil
}
func (s *Store) DeleteIcalEvents(propertyID string) error {
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='airbnb'`, propertyID)
return err
}
func (s *Store) InsertFromIcal(e *Event) error {
e.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
VALUES (?,?,?,?,?,?,?,?)`,
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "airbnb", e.IcalUID, e.Notes,
)
return err
}
func (s *Store) Create(e *Event) error {
e.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, notes) VALUES (?,?,?,?,?,?,?)`,
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, e.Source, e.Notes,
)
return err
}
func (s *Store) Update(e *Event) error {
_, err := s.db.Exec(
`UPDATE calendar_events SET title=?, start_date=?, end_date=?, notes=? WHERE id=? AND source='manual'`,
e.Title, e.StartDate, e.EndDate, e.Notes, e.ID,
)
return err
}
func (s *Store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE id=? AND source='manual'`, id)
return err
}
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
s.db.Exec(
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
uuid.NewString(), propertyID, status, imported, nullStr(errMsg),
)
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
events, err := h.store.List(q.Get("property_id"), q.Get("from"), q.Get("to"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if events == nil {
events = []Event{}
}
respond(w, events)
}
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
e.Source = "manual"
if err := h.store.Create(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, e)
}
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
e.ID = mux.Vars(r)["id"]
if err := h.store.Update(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, e)
}
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
// TODO: calcul taux d'occupation par mois
respond(w, []OccupancyStats{})
}
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
// Le service iCal expose un endpoint pour forcer la sync
respond(w, map[string]string{"status": "sync triggered"})
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}