package calendar import ( "bytes" "database/sql" "encoding/csv" "encoding/json" "fmt" "io" "net/http" "path/filepath" "strings" "time" xls "github.com/extrame/xls" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/xuri/excelize/v2" ) // ── 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) DeleteBookingEvents(propertyID string) error { _, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='booking'`, propertyID) return err } func (s *Store) InsertFromBooking(e *Event) error { e.ID = uuid.NewString() _, err := s.db.Exec( `INSERT OR REPLACE 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, "booking", e.IcalUID, e.Notes, ) 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) { respond(w, map[string]string{"status": "sync triggered"}) } func (h *Handler) ImportBookingCSV(w http.ResponseWriter, r *http.Request) { propertyID := r.FormValue("property_id") if propertyID == "" { http.Error(w, "property_id requis", http.StatusBadRequest) return } f, fh, err := r.FormFile("file") if err != nil { http.Error(w, "fichier requis", http.StatusBadRequest) return } defer f.Close() data, err := io.ReadAll(f) if err != nil { http.Error(w, "erreur lecture fichier", http.StatusInternalServerError) return } var events []Event ext := strings.ToLower(filepath.Ext(fh.Filename)) switch ext { case ".xlsx": events, err = parseBookingXLSX(data, propertyID) case ".xls": events, err = parseBookingXLS(data, propertyID) default: // .csv events, err = parseBookingCSV(bytes.NewReader(data), propertyID) } if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := h.store.DeleteBookingEvents(propertyID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } imported := 0 for _, e := range events { if err := h.store.InsertFromBooking(&e); err != nil { continue } imported++ } respond(w, map[string]int{"imported": imported, "total": len(events)}) } // extractBookingRows convertit des lignes (header + data) en Event func extractBookingRows(rows [][]string, propertyID string) []Event { if len(rows) == 0 { return nil } col := make(map[string]int) for i, h := range rows[0] { col[strings.TrimSpace(h)] = i } get := func(row []string, name string) string { i, ok := col[name] if !ok || i >= len(row) { return "" } return strings.TrimSpace(row[i]) } var events []Event for _, row := range rows[1:] { status := get(row, "Status") if status == "cancelled" || status == "invalid" || status == "no_show" || status == "" { continue } bookNum := get(row, "Book number") checkin := normalizeBookingDate(get(row, "Check-in")) checkout := normalizeBookingDate(get(row, "Check-out")) if bookNum == "" || checkin == "" || checkout == "" { continue } notes := get(row, "Price") if r := get(row, "Remarks"); r != "" { if notes != "" { notes += " — " + r } else { notes = r } } events = append(events, Event{ PropertyID: propertyID, IcalUID: "booking-" + bookNum, Title: get(row, "Guest name(s)"), StartDate: checkin, EndDate: checkout, Source: "booking", Notes: notes, }) } return events } // normalizeBookingDate accepte YYYY-MM-DD, DD/MM/YYYY et MM/DD/YYYY func normalizeBookingDate(s string) string { s = strings.TrimSpace(s) if len(s) >= 10 && s[4] == '-' { return s[:10] // déjà YYYY-MM-DD } if len(s) == 10 && s[2] == '/' { // DD/MM/YYYY (format européen Booking) return s[6:10] + "-" + s[3:5] + "-" + s[0:2] } return s } func parseBookingCSV(r io.Reader, propertyID string) ([]Event, error) { reader := csv.NewReader(r) rows, err := reader.ReadAll() if err != nil { return nil, err } return extractBookingRows(rows, propertyID), nil } func parseBookingXLSX(data []byte, propertyID string) ([]Event, error) { f, err := excelize.OpenReader(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("erreur lecture XLSX: %w", err) } defer f.Close() rows, err := f.GetRows(f.GetSheetName(0)) if err != nil { return nil, err } return extractBookingRows(rows, propertyID), nil } func parseBookingXLS(data []byte, propertyID string) ([]Event, error) { wb, err := xls.OpenReader(bytes.NewReader(data), "utf-8") if err != nil { return nil, fmt.Errorf("erreur lecture XLS: %w", err) } sheet := wb.GetSheet(0) if sheet == nil { return nil, fmt.Errorf("feuille Excel vide") } var rows [][]string for i := 0; i <= int(sheet.MaxRow); i++ { row := sheet.Row(i) var cells []string for j := 0; j < row.LastCol(); j++ { cells = append(cells, row.Col(j)) } rows = append(rows, cells) } return extractBookingRows(rows, propertyID), nil } 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 }