up
This commit is contained in:
@@ -29,6 +29,7 @@ frontend/.env.*.local
|
|||||||
|
|
||||||
# ── Web embed (build artifact) ────────────────────────────────────────────────
|
# ── Web embed (build artifact) ────────────────────────────────────────────────
|
||||||
web/dist/
|
web/dist/
|
||||||
|
web/build/
|
||||||
|
|
||||||
# ── Environnement ─────────────────────────────────────────────────────────────
|
# ── Environnement ─────────────────────────────────────────────────────────────
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func main() {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(results)
|
json.NewEncoder(w).Encode(results)
|
||||||
}).Methods("POST")
|
}).Methods("POST")
|
||||||
|
protected.HandleFunc("/calendar/import-booking", calendarHandler.ImportBookingCSV).Methods("POST")
|
||||||
protected.HandleFunc("/calendar/{id}", calendarHandler.UpdateEvent).Methods("PUT")
|
protected.HandleFunc("/calendar/{id}", calendarHandler.UpdateEvent).Methods("PUT")
|
||||||
protected.HandleFunc("/calendar/{id}", calendarHandler.DeleteEvent).Methods("DELETE")
|
protected.HandleFunc("/calendar/{id}", calendarHandler.DeleteEvent).Methods("DELETE")
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@
|
|||||||
let occupied = 0;
|
let occupied = 0;
|
||||||
for (let d = 1; d <= days; d++) {
|
for (let d = 1; d <= days; d++) {
|
||||||
const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||||
if (calEvents.some(e => ds >= e.start_date && ds < e.end_date)) occupied++;
|
if (calEvents.some(e => ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date))) occupied++;
|
||||||
}
|
}
|
||||||
return days > 0 ? Math.round((occupied/days)*100) : 0;
|
return days > 0 ? Math.round((occupied/days)*100) : 0;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/stores/api.js';
|
import { api } from '$lib/stores/api.js';
|
||||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw } from 'lucide-svelte';
|
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw, Upload } from 'lucide-svelte';
|
||||||
|
|
||||||
let properties = [];
|
let properties = [];
|
||||||
let events = [];
|
let events = [];
|
||||||
let loading = true;
|
|
||||||
let filterProperty = '';
|
let filterProperty = '';
|
||||||
let showForm = false;
|
let showForm = false;
|
||||||
let error = '';
|
let error = '';
|
||||||
@@ -28,14 +27,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
|
||||||
const from = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-01`;
|
const from = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-01`;
|
||||||
const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
|
const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
const to = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${lastDay}`;
|
const to = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${lastDay}`;
|
||||||
const params = { from, to };
|
const params = { from, to };
|
||||||
if (filterProperty) params.property_id = filterProperty;
|
if (filterProperty) params.property_id = filterProperty;
|
||||||
events = await api.calendar.list(params) || [];
|
events = await api.calendar.list(params) || [];
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth() {
|
function prevMonth() {
|
||||||
@@ -91,11 +88,6 @@
|
|||||||
const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||||
const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
|
const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
|
||||||
|
|
||||||
const sourceBg = {
|
|
||||||
airbnb: 'bg-orange-100 dark:bg-orange-900',
|
|
||||||
manual: 'bg-blue-100 dark:bg-blue-900'
|
|
||||||
};
|
|
||||||
|
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
let syncMsg = '';
|
let syncMsg = '';
|
||||||
let syncError = '';
|
let syncError = '';
|
||||||
@@ -130,6 +122,34 @@
|
|||||||
await load();
|
await load();
|
||||||
} catch (e) { error = e.message; }
|
} catch (e) { error = e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Import Booking.com CSV ────────────────────────────────────────────────
|
||||||
|
let showBookingImport = false;
|
||||||
|
let bookingPropertyId = '';
|
||||||
|
let bookingFile = null;
|
||||||
|
let bookingImporting = false;
|
||||||
|
let bookingMsg = '';
|
||||||
|
let bookingError = '';
|
||||||
|
|
||||||
|
async function importBookingCSV() {
|
||||||
|
bookingError = '';
|
||||||
|
bookingMsg = '';
|
||||||
|
if (!bookingPropertyId) { bookingError = 'Sélectionner un bien.'; return; }
|
||||||
|
if (!bookingFile) { bookingError = 'Sélectionner un fichier CSV.'; return; }
|
||||||
|
bookingImporting = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('property_id', bookingPropertyId);
|
||||||
|
fd.append('file', bookingFile);
|
||||||
|
const res = await fetch('/api/calendar/import-booking', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const r = await res.json();
|
||||||
|
bookingMsg = `${r.imported} réservation(s) importée(s) sur ${r.total}`;
|
||||||
|
showBookingImport = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) { bookingError = e.message; }
|
||||||
|
bookingImporting = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6 max-w-5xl mx-auto">
|
<div class="p-6 max-w-5xl mx-auto">
|
||||||
@@ -144,6 +164,10 @@
|
|||||||
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50">
|
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50">
|
||||||
<RefreshCw size={15} class={syncing ? 'animate-spin' : ''}/> Synchroniser
|
<RefreshCw size={15} class={syncing ? 'animate-spin' : ''}/> Synchroniser
|
||||||
</button>
|
</button>
|
||||||
|
<button on:click={() => { showBookingImport = true; bookingMsg = ''; bookingError = ''; bookingFile = null; }}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 border border-teal-200 dark:border-teal-700 rounded-lg text-sm text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors">
|
||||||
|
<Upload size={15}/> Booking.com
|
||||||
|
</button>
|
||||||
<button on:click={() => { showForm = true; form = empty(); error = ''; }}
|
<button on:click={() => { showForm = true; form = empty(); error = ''; }}
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
<Plus size={16}/> Ajouter occupation
|
<Plus size={16}/> Ajouter occupation
|
||||||
@@ -157,6 +181,9 @@
|
|||||||
{#if syncError}
|
{#if syncError}
|
||||||
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
|
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if bookingMsg}
|
||||||
|
<p class="text-sm text-teal-600 dark:text-teal-400 mb-3">{bookingMsg}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Filtres + navigation -->
|
<!-- Filtres + navigation -->
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||||
@@ -198,20 +225,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Jours -->
|
<!-- Jours -->
|
||||||
<div class="grid grid-cols-7">
|
<div class="grid grid-cols-7">
|
||||||
{#each calendarDays as day, i}
|
{#each calendarDays as day}
|
||||||
{@const event = eventByDay[day] ?? null}
|
{@const event = eventByDay[day] ?? null}
|
||||||
{@const occupied = !!event}
|
{@const occupied = !!event}
|
||||||
<div class="border-b border-r border-gray-50 dark:border-gray-800/50 min-h-[72px] p-2 relative
|
<div class="border-b border-r border-gray-50 dark:border-gray-800/50 min-h-[72px] p-2 relative
|
||||||
{!day ? 'bg-gray-50/50 dark:bg-gray-800/20' : ''}
|
{!day ? 'bg-gray-50/50 dark:bg-gray-800/20' : ''}
|
||||||
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : 'bg-blue-50 dark:bg-blue-950/30') : ''}">
|
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : event.source === 'booking' ? 'bg-teal-50 dark:bg-teal-950/30' : 'bg-blue-50 dark:bg-blue-950/30') : ''}">
|
||||||
{#if day}
|
{#if day}
|
||||||
<span class="text-xs font-medium {isToday(day) ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full' : 'text-gray-700 dark:text-gray-300'}">
|
<span class="text-xs font-medium {isToday(day) ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full' : 'text-gray-700 dark:text-gray-300'}">
|
||||||
{day}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
{#if event && event.start_date === `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`}
|
{#if event && event.start_date === `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`}
|
||||||
<div class="mt-1 text-xs px-1.5 py-0.5 rounded font-medium truncate
|
<div class="mt-1 text-xs px-1.5 py-0.5 rounded font-medium truncate
|
||||||
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
|
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : event.source === 'booking' ? 'bg-teal-200 text-teal-800 dark:bg-teal-900 dark:text-teal-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
|
||||||
{event.title || (event.source === 'airbnb' ? 'Airbnb' : 'Locataire')}
|
{event.title || (event.source === 'airbnb' ? 'Airbnb' : event.source === 'booking' ? 'Booking.com' : 'Locataire')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -223,13 +250,15 @@
|
|||||||
<!-- Légende -->
|
<!-- Légende -->
|
||||||
<div class="flex gap-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
|
<div class="flex gap-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-orange-200 dark:bg-orange-900"></span>Airbnb (sync auto)</span>
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-orange-200 dark:bg-orange-900"></span>Airbnb (sync auto)</span>
|
||||||
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-teal-200 dark:bg-teal-900"></span>Booking.com (import CSV)</span>
|
||||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-blue-200 dark:bg-blue-900"></span>Manuel</span>
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-blue-200 dark:bg-blue-900"></span>Manuel</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||||
|
<div role="dialog" aria-modal="true" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter une période d'occupation</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter une période d'occupation</h2>
|
||||||
@@ -238,27 +267,27 @@
|
|||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
|
<label for="form-property" class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
|
||||||
<select bind:value={form.property_id}
|
<select id="form-property" bind:value={form.property_id}
|
||||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
|
<label for="form-title" class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
|
||||||
<input bind:value={form.title} placeholder="Ex: Famille Dupont"
|
<input id="form-title" bind:value={form.title} placeholder="Ex: Famille Dupont"
|
||||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
|
<label for="form-start" class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
|
||||||
<input type="date" bind:value={form.start_date}
|
<input id="form-start" type="date" bind:value={form.start_date}
|
||||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
|
<label for="form-end" class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
|
||||||
<input type="date" bind:value={form.end_date}
|
<input id="form-end" type="date" bind:value={form.end_date}
|
||||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,3 +302,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal import Booking.com -->
|
||||||
|
{#if showBookingImport}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||||
|
<div role="dialog" aria-modal="true" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showBookingImport = false}>
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<h2 class="font-semibold text-gray-900 dark:text-white">Importer réservations Booking.com</h2>
|
||||||
|
<button on:click={() => showBookingImport = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-5 space-y-4">
|
||||||
|
{#if bookingError}<p class="text-red-500 text-sm">{bookingError}</p>{/if}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
|
||||||
|
<select bind:value={bookingPropertyId}
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500">
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1">Fichier CSV Booking.com *</label>
|
||||||
|
<input type="file" accept=".xls,.xlsx,.csv"
|
||||||
|
on:change={(e) => bookingFile = e.target.files[0]}
|
||||||
|
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-medium file:bg-teal-50 file:text-teal-700 hover:file:bg-teal-100 dark:file:bg-teal-900/30 dark:file:text-teal-300"/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Extranet Booking.com → Réservations → Exporter → CSV</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400">Les réservations annulées sont ignorées. L'import remplace les données Booking.com précédentes pour ce bien.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<button on:click={() => showBookingImport = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||||
|
<button on:click={importBookingCSV} disabled={bookingImporting}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{#if bookingImporting}<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>{:else}<Upload size={15}/>{/if}
|
||||||
|
Importer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -6,16 +6,26 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/crypto v0.24.0
|
golang.org/x/crypto v0.48.0
|
||||||
modernc.org/sqlite v1.47.0
|
modernc.org/sqlite v1.47.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||||
|
github.com/extrame/xls v0.0.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
|
||||||
|
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
|
||||||
|
github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k=
|
||||||
|
github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
@@ -13,11 +17,29 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
|||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package calendar
|
package calendar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xls "github.com/extrame/xls"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Models ────────────────────────────────────────────────────────────────────
|
// ── Models ────────────────────────────────────────────────────────────────────
|
||||||
@@ -121,6 +129,21 @@ func (s *Store) Delete(id string) error {
|
|||||||
return err
|
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) {
|
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
|
||||||
s.db.Exec(
|
s.db.Exec(
|
||||||
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
|
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
|
||||||
@@ -190,10 +213,162 @@ func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
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"})
|
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) {
|
func respond(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -36,10 +37,53 @@ func Migrate(db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := migrateAddBookingSource(db); err != nil {
|
||||||
|
log.Printf("⚠ migration booking source: %v", err)
|
||||||
|
}
|
||||||
log.Println("✓ Migrations appliquées")
|
log.Println("✓ Migrations appliquées")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateAddBookingSource ajoute 'booking' aux sources autorisées de calendar_events.
|
||||||
|
// Vérifie d'abord le DDL pour ne s'exécuter qu'une seule fois.
|
||||||
|
func migrateAddBookingSource(db *sql.DB) error {
|
||||||
|
var ddl string
|
||||||
|
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE name='calendar_events' AND type='table'`).Scan(&ddl); err != nil {
|
||||||
|
return nil // table absente, rien à faire
|
||||||
|
}
|
||||||
|
if strings.Contains(ddl, "'booking'") {
|
||||||
|
return nil // déjà migré
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, step := range []string{
|
||||||
|
`CREATE TABLE calendar_events_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
|
||||||
|
title TEXT,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
source TEXT NOT NULL CHECK(source IN ('airbnb','manual','booking')),
|
||||||
|
ical_uid TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(property_id, ical_uid)
|
||||||
|
)`,
|
||||||
|
`INSERT INTO calendar_events_new SELECT * FROM calendar_events`,
|
||||||
|
`DROP TABLE calendar_events`,
|
||||||
|
`ALTER TABLE calendar_events_new RENAME TO calendar_events`,
|
||||||
|
} {
|
||||||
|
if _, err := tx.Exec(step); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("✓ Migration calendar_events: source 'booking' ajouté")
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
const sqlCreateUsers = `
|
const sqlCreateUsers = `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
Reference in New Issue
Block a user