mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
Feature: Ajout de rendez-vous Google Calendar
Ajout d'un modal complet pour créer des événements directement dans Google Calendar avec toutes les fonctionnalités : Backend (routes/calendar.routes.js) : - Changement du scope de 'calendar.readonly' à 'calendar' pour permettre l'écriture - Nouvelle route POST /api/calendar/events pour créer des événements - Support complet des fonctionnalités Google Calendar : * Titre, description, dates de début/fin * Lieu * Participants (attendees) avec notifications automatiques * Rappels personnalisables (popup) * Récurrence (quotidienne, hebdomadaire, mensuelle, annuelle) * Visibilité (public, privé, par défaut) * Couleurs d'événement (11 couleurs disponibles) * Fuseau horaire Europe/Paris - Synchronisation automatique après création pour mise à jour locale Frontend : - Service CalendarService.ts : nouvelle méthode createEvent() - Bouton "Ajouter" dans la box Rendez-vous - Modal complet avec formulaire de création d'événement - Tous les champs Google Calendar disponibles : * Titre et description * Date/heure de début et fin (datetime-local) * Lieu * Participants (emails séparés par virgules) * Rappels (10min, 30min, 1h, 1 jour) * Récurrence (quotidienne, hebdomadaire, mensuelle, annuelle) * Visibilité (public/privé) * Couleur (11 options) - Validation des champs obligatoires - Messages de succès/erreur - Rechargement automatique de la liste après création
This commit is contained in:
3 files changed
+320
-13
No files matched your search
+112
-2
@@ -6,8 +6,8 @@ const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
// Scopes Google Calendar requis
|
||||
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
||||
// Scopes Google Calendar requis (lecture ET écriture)
|
||||
const SCOPES = ['https://www.googleapis.com/auth/calendar'];
|
||||
|
||||
/**
|
||||
* Types d'authentification supportés
|
||||
@@ -496,6 +496,116 @@ router.post('/disconnect', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/calendar/events
|
||||
* Créer un événement dans Google Calendar
|
||||
*/
|
||||
router.post('/events', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
location,
|
||||
attendees,
|
||||
reminders,
|
||||
recurrence,
|
||||
visibility,
|
||||
colorId
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
if (!title || !startDateTime || !endDateTime) {
|
||||
return res.status(400).json({ error: 'Titre, date de début et date de fin requis' });
|
||||
}
|
||||
|
||||
const calendar = await getCalendarClient(req.user.id);
|
||||
|
||||
// Préparer l'événement
|
||||
const event = {
|
||||
summary: title,
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
start: {
|
||||
dateTime: startDateTime,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
end: {
|
||||
dateTime: endDateTime,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
attendees: attendees && attendees.length > 0 ?
|
||||
attendees.map(email => ({ email })) : undefined,
|
||||
reminders: reminders ? {
|
||||
useDefault: false,
|
||||
overrides: reminders
|
||||
} : undefined,
|
||||
recurrence: recurrence || undefined,
|
||||
visibility: visibility || 'default',
|
||||
colorId: colorId || undefined
|
||||
};
|
||||
|
||||
// Récupérer le Calendar ID depuis les settings
|
||||
const calendarIdSetting = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_id'");
|
||||
const calendarId = calendarIdSetting?.value || 'primary';
|
||||
|
||||
// Créer l'événement dans Google Calendar
|
||||
const response = await calendar.events.insert({
|
||||
calendarId: calendarId,
|
||||
resource: event,
|
||||
sendUpdates: attendees && attendees.length > 0 ? 'all' : 'none'
|
||||
});
|
||||
|
||||
logger.info(`Événement créé: ${response.data.id} pour l'utilisateur ${req.user.username}`);
|
||||
|
||||
// Synchroniser après création pour mettre à jour la base locale
|
||||
try {
|
||||
const events = await calendar.events.list({
|
||||
calendarId: calendarId,
|
||||
timeMin: new Date().toISOString(),
|
||||
maxResults: 10,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime'
|
||||
});
|
||||
|
||||
if (events.data.items && events.data.items.length > 0) {
|
||||
for (const item of events.data.items) {
|
||||
await runQuery(`
|
||||
INSERT OR REPLACE INTO calendar_events
|
||||
(google_event_id, user_id, title, description, start_time, end_time, location, html_link, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`, [
|
||||
item.id,
|
||||
req.user.id,
|
||||
item.summary || 'Sans titre',
|
||||
item.description || null,
|
||||
item.start.dateTime || item.start.date,
|
||||
item.end.dateTime || item.end.date,
|
||||
item.location || null,
|
||||
item.htmlLink || null
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (syncError) {
|
||||
logger.warn('Erreur lors de la synchronisation post-création:', syncError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Événement créé avec succès',
|
||||
eventId: response.data.id,
|
||||
htmlLink: response.data.htmlLink
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la création de l\'événement:', error);
|
||||
res.status(500).json({
|
||||
error: 'Erreur lors de la création de l\'événement',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/calendar/events/:id
|
||||
* Supprimer un événement local
|
||||
|
||||
+189
-11
@@ -79,6 +79,7 @@ const Index = () => {
|
||||
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
||||
const [addTagModal, setAddTagModal] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [addEventModal, setAddEventModal] = useState(false);
|
||||
|
||||
// Debounce timer for auto-save
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -712,17 +713,22 @@ const Index = () => {
|
||||
<CalendarIcon className="h-6 w-6" />
|
||||
Rendez-vous
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={async () => {
|
||||
try {
|
||||
const result = await CalendarService.sync();
|
||||
showSuccess(`${result.syncedCount} événements synchronisés`);
|
||||
await loadCalendarEvents();
|
||||
} catch (error) {
|
||||
showError("Erreur lors de la synchronisation");
|
||||
}
|
||||
}} className="gap-1">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddEventModal(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={async () => {
|
||||
try {
|
||||
const result = await CalendarService.sync();
|
||||
showSuccess(`${result.syncedCount} événements synchronisés`);
|
||||
await loadCalendarEvents();
|
||||
} catch (error) {
|
||||
showError("Erreur lors de la synchronisation");
|
||||
}
|
||||
}}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1933,6 +1939,178 @@ const Index = () => {
|
||||
confirmText="Ajouter"
|
||||
/>
|
||||
|
||||
{/* Add Event Modal */}
|
||||
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajouter un rendez-vous</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créer un événement dans votre Google Calendar
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
title: formData.get('title') as string,
|
||||
description: formData.get('description') as string || undefined,
|
||||
startDateTime: new Date(formData.get('startDateTime') as string).toISOString(),
|
||||
endDateTime: new Date(formData.get('endDateTime') as string).toISOString(),
|
||||
location: formData.get('location') as string || undefined,
|
||||
attendees: (formData.get('attendees') as string || '').split(',').map(e => e.trim()).filter(e => e),
|
||||
reminders: formData.get('reminderMinutes') ? [
|
||||
{ method: 'popup', minutes: parseInt(formData.get('reminderMinutes') as string) }
|
||||
] : undefined,
|
||||
recurrence: formData.get('recurrence') ? [formData.get('recurrence') as string] : undefined,
|
||||
visibility: formData.get('visibility') as string || 'default',
|
||||
colorId: formData.get('colorId') as string || undefined
|
||||
};
|
||||
|
||||
await CalendarService.createEvent(eventData);
|
||||
showSuccess('Événement créé avec succès');
|
||||
setAddEventModal(false);
|
||||
await loadCalendarEvents();
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.error || 'Erreur lors de la création de l\'événement');
|
||||
}
|
||||
}} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre *</Label>
|
||||
<Input id="title" name="title" required placeholder="Réunion d'équipe" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="w-full min-h-[80px] px-3 py-2 border border-input rounded-md bg-background"
|
||||
placeholder="Détails de l'événement..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDateTime">Début *</Label>
|
||||
<Input
|
||||
id="startDateTime"
|
||||
name="startDateTime"
|
||||
type="datetime-local"
|
||||
required
|
||||
defaultValue={new Date(Date.now() + 60*60*1000).toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDateTime">Fin *</Label>
|
||||
<Input
|
||||
id="endDateTime"
|
||||
name="endDateTime"
|
||||
type="datetime-local"
|
||||
required
|
||||
defaultValue={new Date(Date.now() + 2*60*60*1000).toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Lieu</Label>
|
||||
<Input id="location" name="location" placeholder="Salle de réunion A" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendees">Participants (emails séparés par des virgules)</Label>
|
||||
<Input
|
||||
id="attendees"
|
||||
name="attendees"
|
||||
type="text"
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminderMinutes">Rappel (minutes avant)</Label>
|
||||
<Select name="reminderMinutes" defaultValue="30">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner un rappel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Aucun</SelectItem>
|
||||
<SelectItem value="10">10 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="60">1 heure</SelectItem>
|
||||
<SelectItem value="1440">1 jour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrence">Récurrence</Label>
|
||||
<Select name="recurrence" defaultValue="">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Pas de récurrence" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Pas de récurrence</SelectItem>
|
||||
<SelectItem value="RRULE:FREQ=DAILY">Tous les jours</SelectItem>
|
||||
<SelectItem value="RRULE:FREQ=WEEKLY">Toutes les semaines</SelectItem>
|
||||
<SelectItem value="RRULE:FREQ=MONTHLY">Tous les mois</SelectItem>
|
||||
<SelectItem value="RRULE:FREQ=YEARLY">Tous les ans</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibilité</Label>
|
||||
<Select name="visibility" defaultValue="default">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner la visibilité" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Par défaut</SelectItem>
|
||||
<SelectItem value="public">Public</SelectItem>
|
||||
<SelectItem value="private">Privé</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="colorId">Couleur</Label>
|
||||
<Select name="colorId" defaultValue="">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Couleur par défaut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Par défaut</SelectItem>
|
||||
<SelectItem value="1">Lavande</SelectItem>
|
||||
<SelectItem value="2">Sauge</SelectItem>
|
||||
<SelectItem value="3">Raisin</SelectItem>
|
||||
<SelectItem value="4">Flamant rose</SelectItem>
|
||||
<SelectItem value="5">Banane</SelectItem>
|
||||
<SelectItem value="6">Mandarine</SelectItem>
|
||||
<SelectItem value="7">Paon</SelectItem>
|
||||
<SelectItem value="8">Graphite</SelectItem>
|
||||
<SelectItem value="9">Myrtille</SelectItem>
|
||||
<SelectItem value="10">Basilic</SelectItem>
|
||||
<SelectItem value="11">Tomate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setAddEventModal(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Créer l'événement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="fixed bottom-4 right-4">
|
||||
<MadeWithDyad />
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,25 @@ class CalendarService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un événement dans Google Calendar
|
||||
*/
|
||||
async createEvent(eventData: {
|
||||
title: string;
|
||||
description?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
location?: string;
|
||||
attendees?: string[];
|
||||
reminders?: Array<{ method: string; minutes: number }>;
|
||||
recurrence?: string[];
|
||||
visibility?: string;
|
||||
colorId?: string;
|
||||
}): Promise<{ eventId: string; htmlLink: string }> {
|
||||
const response = await axios.post(`${this.baseURL}/events`, eventData, this.getAuthHeader());
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default new CalendarService();
|
||||
Reference in new issue
Block a user