octane-website/src/routes/calendar.js

212 lines
6.8 KiB
JavaScript
Raw Normal View History

2026-07-08 15:53:25 +02:00
const express = require('express');
const calendarRepo = require('../repositories/calendarRepo');
const usersRepo = require('../repositories/usersRepo');
2026-07-08 15:53:25 +02:00
const workflowState = require('../lib/calendarWorkflowState');
2026-07-09 17:52:08 +02:00
const calendarSync = require('../services/calendarSync');
const { requireAdmin } = require('../auth/middleware');
2026-07-08 15:53:25 +02:00
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/people',
asyncHandler(async (req, res) => {
res.json(await calendarRepo.getPeople());
})
);
router.get(
'/slots',
asyncHandler(async (req, res) => {
2026-07-08 16:26:23 +02:00
// Default 0, not 1: with no filter the calendar should show every
// ingested slot (heat-colored by availability ratio on the frontend),
// not just slots where at least one person happens to be free.
const minPeople = req.query.min_people !== undefined ? parseInt(req.query.min_people, 10) : 0;
2026-07-08 15:53:25 +02:00
const weeks = req.query.weeks !== undefined ? parseInt(req.query.weeks, 10) : 3;
const personIds = req.query.person_ids
? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n))
: null;
const slots = await calendarRepo.getSlots({ minPeople, personIds, weeks });
res.json(slots);
})
);
router.get(
'/last-checked',
asyncHandler(async (req, res) => {
res.json({ last_checked: await calendarRepo.getLastChecked() });
})
);
router.get('/workflow-status', (req, res) => {
res.json(workflowState.getState());
});
2026-07-09 17:52:08 +02:00
router.post(
'/refresh',
asyncHandler(async (req, res) => {
const feeds = await calendarRepo.findAllFeeds();
if (feeds.length === 0) {
return res.status(400).json({ error: 'no_feeds_configured' });
}
if (workflowState.getState().status === 'running') {
return res.status(409).json({ ok: false, error: 'A refresh is already in progress' });
}
2026-07-08 15:53:25 +02:00
2026-07-09 17:52:08 +02:00
workflowState.setRunning();
res.json({ ok: true });
2026-07-08 15:53:25 +02:00
2026-07-09 17:52:08 +02:00
// Fire-and-forget: the browser polls GET /workflow-status for the result.
(async () => {
try {
const summary = await calendarSync.syncAvailability();
workflowState.setSuccess(
`${summary.slotsProcessed} créneaux traités, ${summary.availabilityRows} disponibilités enregistrées ` +
`(${summary.availableTrueCount} dispo / ${summary.availableFalseCount} indispo)` +
(summary.slotsWithNoPeople > 0 ? `${summary.slotsWithNoPeople} créneaux sans aucune personne, voir les logs serveur` : '') +
(summary.failedFeeds > 0 ? `${summary.failedFeeds} calendrier(s) inaccessible(s), voir les logs serveur` : '')
);
} catch (err) {
console.error('[calendar] availability sync failed:', err.message);
workflowState.setError(err.message);
2026-07-08 15:53:25 +02:00
}
2026-07-09 17:52:08 +02:00
})();
})
);
2026-07-08 15:53:25 +02:00
2026-07-09 18:26:25 +02:00
// Self-service: any authenticated user manages their own calendar feeds here
// (no requireAdmin) — distinct from the /people/:id/feeds admin routes below,
// which let an admin manage anyone's feeds on their behalf.
router.get(
'/my-feeds',
asyncHandler(async (req, res) => {
const feeds = await calendarRepo.findFeedsForUser(req.user.id);
res.json(feeds.map((f) => ({ id: f.id, label: f.label, icsUrl: f.ics_url })));
})
);
router.post(
'/my-feeds',
asyncHandler(async (req, res) => {
const { label, icsUrl } = req.body || {};
if (!icsUrl || !icsUrl.trim()) {
return res.status(400).json({ error: 'ics_url_required' });
}
const feed = await calendarRepo.addFeed(req.user.id, {
label: label ? label.trim() : null,
icsUrl: icsUrl.trim(),
});
res.status(201).json({ id: feed.id, label: feed.label, icsUrl: feed.ics_url });
})
);
router.delete(
'/my-feeds/:feedId',
asyncHandler(async (req, res) => {
const deleted = await calendarRepo.removeFeedForUser(parseInt(req.params.feedId, 10), req.user.id);
if (!deleted) {
return res.status(404).json({ error: 'feed_not_found' });
}
res.status(204).end();
})
);
2026-07-09 17:52:08 +02:00
router.get(
'/people/admin',
requireAdmin,
asyncHandler(async (req, res) => {
const users = await calendarRepo.getUsersWithFeeds();
res.json(
users.map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatar_url,
isAdmin: u.is_admin,
feeds: u.feeds,
}))
);
2026-07-09 17:52:08 +02:00
})
);
router.post(
'/people/:id/feeds',
requireAdmin,
asyncHandler(async (req, res) => {
const { label, icsUrl } = req.body || {};
if (!icsUrl || !icsUrl.trim()) {
return res.status(400).json({ error: 'ics_url_required' });
2026-07-08 15:53:25 +02:00
}
const userId = parseInt(req.params.id, 10);
const user = await usersRepo.findById(userId);
if (!user) {
return res.status(404).json({ error: 'user_not_found' });
}
const feed = await calendarRepo.addFeed(userId, {
2026-07-09 17:52:08 +02:00
label: label ? label.trim() : null,
icsUrl: icsUrl.trim(),
});
res.status(201).json({ id: feed.id, userId: feed.user_id, label: feed.label, icsUrl: feed.ics_url });
2026-07-09 17:52:08 +02:00
})
);
router.delete(
'/feeds/:feedId',
requireAdmin,
asyncHandler(async (req, res) => {
await calendarRepo.removeFeed(parseInt(req.params.feedId, 10));
res.status(204).end();
})
);
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
router.get(
'/settings',
requireAdmin,
asyncHandler(async (req, res) => {
const settings = await calendarRepo.getSlotSettings();
res.json({
weekdayStart: formatTime(settings.weekday.startHour, settings.weekday.startMinute),
weekdayEnd: formatTime(settings.weekday.endHour, settings.weekday.endMinute),
weekendStart: formatTime(settings.weekend.startHour, settings.weekend.startMinute),
weekendEnd: formatTime(settings.weekend.endHour, settings.weekend.endMinute),
marginMinutes: settings.marginMinutes,
});
})
);
function formatTime(hour, minute) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
router.patch(
'/settings',
requireAdmin,
asyncHandler(async (req, res) => {
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd };
for (const [key, value] of Object.entries(times)) {
if (!TIME_RE.test(value || '')) {
return res.status(400).json({ error: 'invalid_time', field: key });
}
}
if (weekdayStart >= weekdayEnd) {
return res.status(400).json({ error: 'weekday_start_must_be_before_end' });
}
if (weekendStart >= weekendEnd) {
return res.status(400).json({ error: 'weekend_start_must_be_before_end' });
}
const margin = Number(marginMinutes);
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
return res.status(400).json({ error: 'invalid_margin_minutes' });
}
const values = { ...times, marginMinutes: margin };
await calendarRepo.updateSlotSettings(values);
res.json(values);
})
);
2026-07-08 15:53:25 +02:00
module.exports = router;