fix: delete n8n old calendar to keep only cal one

This commit is contained in:
Nathan FONTEYNE 2026-07-09 18:15:34 +02:00
parent 63fb6cd45a
commit 6de6cfacf2
9 changed files with 220 additions and 131 deletions

View file

@ -313,7 +313,7 @@ La garantie de confidentialité ne repose donc pas sur le fournisseur, mais sur
### Personnes suivies ### Personnes suivies
La liste des personnes (et leur couleur) est amorcée en base par la migration `006_calendar_seed_people.sql` (Nathan, Raphaël, Yann, Jules, AK). De nouvelles personnes peuvent être ajoutées directement en base ; leurs flux de calendrier se gèrent ensuite depuis `/admin.html`. Les personnes affichées sur le calendrier ne sont pas une liste séparée à maintenir : ce sont directement les utilisateurs de l'application (comptes Authentik) ayant au moins un calendrier enregistré. Depuis `/admin.html`, section "Calendriers des membres", un admin voit tous les utilisateurs et peut attacher un ou plusieurs flux ICS à n'importe lequel d'entre eux — dès qu'un utilisateur a au moins un flux, il apparaît automatiquement sur `/calendar.html` (avec une couleur assignée automatiquement, sans configuration). Un utilisateur sans flux configuré n'apparaît pas.
## Prérequis ## Prérequis

View file

@ -180,21 +180,55 @@ nav#main-nav {
margin-left: auto; margin-left: auto;
} }
.nav-user > a { .nav-profile {
color: var(--accent); position: relative;
text-decoration: none;
font-weight: 500;
} }
.nav-profile-link { .nav-profile-trigger {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--text) !important; background: transparent;
border: none;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
color: var(--text);
font-weight: 500; font-weight: 500;
font-size: 0.88rem;
cursor: pointer;
} }
.nav-profile-link span { color: var(--text); } .nav-profile-trigger:hover { background: var(--surface-alt); }
.nav-profile-trigger span { color: var(--text); }
.nav-profile-dropdown {
display: none;
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 0.35rem;
flex-direction: column;
z-index: 30;
}
.nav-profile-dropdown.open { display: flex; }
.nav-profile-dropdown a {
color: var(--text);
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-sm);
}
.nav-profile-dropdown a:hover { background: var(--surface-alt); }
#theme-toggle { #theme-toggle {
border-radius: 999px; border-radius: 999px;
@ -1068,16 +1102,31 @@ a.back-link:hover { color: var(--accent); }
.nav-user { .nav-user {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
gap: 0; gap: 0;
margin-left: 0; margin-left: 0;
} }
.nav-user > a, .nav-profile-trigger {
.nav-user > .nav-profile-link {
width: 100%; width: 100%;
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-radius: 0;
}
.nav-profile-dropdown {
position: static;
box-shadow: none;
border: none;
padding: 0;
min-width: 0;
}
.nav-profile-dropdown a {
width: 100%;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
} }
main { padding: 1.25rem 0.9rem 3rem; } main { padding: 1.25rem 0.9rem 3rem; }
@ -1330,13 +1379,6 @@ a.back-link:hover { color: var(--accent); }
gap: 0.6rem; gap: 0.6rem;
} }
.calendar-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.calendar-feeds { .calendar-feeds {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -56,16 +56,16 @@ function feedRowTemplate(feed) {
`; `;
} }
function calendarPersonRowTemplate(person) { function calendarUserRowTemplate(user) {
return ` return `
<div class="admin-user-row calendar-person-row" data-person-id="${person.id}"> <div class="admin-user-row calendar-person-row" data-user-id="${user.id}">
<div class="admin-user-identity"> <div class="admin-user-identity">
<span class="calendar-color-dot" style="background:${escapeHtml(person.color)}"></span> ${avatarHtml(user, 'avatar-sm')}
<div class="card-title">${escapeHtml(person.name)}</div> <div class="card-title">${escapeHtml(user.name)}${user.isAdmin ? ' <span class="badge">admin</span>' : ''}</div>
</div> </div>
<div class="calendar-feeds"> <div class="calendar-feeds">
${person.feeds.length ? person.feeds.map(feedRowTemplate).join('') : '<p class="empty">Aucun calendrier configuré.</p>'} ${user.feeds.length ? user.feeds.map(feedRowTemplate).join('') : '<p class="empty">Aucun calendrier configuré.</p>'}
<form class="inline-form add-feed-form" data-person-id="${person.id}"> <form class="inline-form add-feed-form" data-user-id="${user.id}">
<input name="label" placeholder="Libellé (optionnel)"> <input name="label" placeholder="Libellé (optionnel)">
<input name="icsUrl" type="url" placeholder="URL du calendrier (.ics)" required> <input name="icsUrl" type="url" placeholder="URL du calendrier (.ics)" required>
<button type="submit" class="secondary icon-btn">+ Ajouter</button> <button type="submit" class="secondary icon-btn">+ Ajouter</button>
@ -75,29 +75,29 @@ function calendarPersonRowTemplate(person) {
`; `;
} }
async function loadCalendarPeople() { async function loadCalendarUsers() {
const people = await api.get('/api/calendar/people/admin'); const users = await api.get('/api/calendar/people/admin');
const container = document.getElementById('calendar-people-list'); const container = document.getElementById('calendar-people-list');
container.innerHTML = people.length container.innerHTML = users.length
? people.map(calendarPersonRowTemplate).join('') ? users.map(calendarUserRowTemplate).join('')
: '<p class="empty">Aucune personne suivie pour le moment.</p>'; : '<p class="empty">Aucun utilisateur pour le moment.</p>';
container.querySelectorAll('.add-feed-form').forEach((form) => { container.querySelectorAll('.add-feed-form').forEach((form) => {
form.addEventListener('submit', (e) => onAddFeed(e, parseInt(form.dataset.personId, 10))); form.addEventListener('submit', (e) => onAddFeed(e, parseInt(form.dataset.userId, 10)));
}); });
container.querySelectorAll('.remove-feed-btn').forEach((btn) => { container.querySelectorAll('.remove-feed-btn').forEach((btn) => {
btn.addEventListener('click', () => onRemoveFeed(parseInt(btn.dataset.feedId, 10))); btn.addEventListener('click', () => onRemoveFeed(parseInt(btn.dataset.feedId, 10)));
}); });
} }
async function onAddFeed(e, personId) { async function onAddFeed(e, userId) {
e.preventDefault(); e.preventDefault();
const form = e.target; const form = e.target;
const label = form.label.value.trim(); const label = form.label.value.trim();
const icsUrl = form.icsUrl.value.trim(); const icsUrl = form.icsUrl.value.trim();
try { try {
await api.post(`/api/calendar/people/${personId}/feeds`, { label, icsUrl }); await api.post(`/api/calendar/people/${userId}/feeds`, { label, icsUrl });
await loadCalendarPeople(); await loadCalendarUsers();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@ -106,7 +106,7 @@ async function onAddFeed(e, personId) {
async function onRemoveFeed(feedId) { async function onRemoveFeed(feedId) {
try { try {
await api.del(`/api/calendar/feeds/${feedId}`); await api.del(`/api/calendar/feeds/${feedId}`);
await loadCalendarPeople(); await loadCalendarUsers();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@ -197,9 +197,10 @@ async function onSaveSlotSettings(e) {
<h2>Calendriers des membres</h2> <h2>Calendriers des membres</h2>
<p class="note"> <p class="note">
Chaque personne peut avoir plusieurs calendriers (Google, Outlook, Apple...). L'application ne Chaque utilisateur de l'application peut avoir plusieurs calendriers (Google, Outlook, Apple...).
conserve jamais le contenu de ces calendriers seul un statut disponible/occupé par créneau est Seuls les utilisateurs avec au moins un calendrier configuré apparaissent sur `/calendar.html`.
déduit et enregistré. L'application ne conserve jamais le contenu de ces calendriers seul un statut disponible/occupé
par créneau est déduit et enregistré.
</p> </p>
<div class="panel admin-user-list" id="calendar-people-list"> <div class="panel admin-user-list" id="calendar-people-list">
<p class="empty">Chargement</p> <p class="empty">Chargement</p>
@ -207,7 +208,7 @@ async function onSaveSlotSettings(e) {
`; `;
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings); document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
await loadSlotSettingsForm(); await loadSlotSettingsForm();
await loadCalendarPeople(); await loadCalendarUsers();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }

View file

@ -177,14 +177,14 @@ function buildFilters() {
const btn = document.getElementById('btn-refresh'); const btn = document.getElementById('btn-refresh');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Actualisation…'; btn.textContent = 'Actualisation…';
showToast('Déclenchement du workflow n8n…'); showToast('Synchronisation des calendriers…');
try { try {
await api.post('/api/calendar/refresh', {}); await api.post('/api/calendar/refresh', {});
showToast('Workflow en cours — en attente des résultats…'); showToast('Synchronisation en cours — en attente des résultats…');
pollWorkflowStatus(btn); pollWorkflowStatus(btn);
} catch (err) { } catch (err) {
const message = err.message === 'n8n_not_configured' const message = err.message === 'no_feeds_configured'
? "L'actualisation automatique n'est pas configurée (n8n)." ? "Aucun calendrier n'est configuré — voir la page Administration."
: err.message; : err.message;
showToast(message, true); showToast(message, true);
resetRefreshButton(btn); resetRefreshButton(btn);

View file

@ -34,14 +34,18 @@ async function initNav(activePage) {
${me.isAdmin ? `<a href="/admin.html" class="${activePage === 'admin' ? 'active' : ''}">Administration</a>` : ''} ${me.isAdmin ? `<a href="/admin.html" class="${activePage === 'admin' ? 'active' : ''}">Administration</a>` : ''}
</div> </div>
<div class="nav-user"> <div class="nav-user">
${me.authentikAccountUrl ? `<a href="${escapeHtml(me.authentikAccountUrl)}" target="_blank" rel="noopener" title="Gérer mon compte Authentik (mot de passe, etc.)">Mon compte</a>` : ''} <div class="nav-profile" id="nav-profile">
<a class="nav-profile-link" href="/profile.html"> <button type="button" class="nav-profile-trigger" id="nav-profile-trigger" aria-haspopup="true" aria-expanded="false">
${avatarHtml(me, 'avatar-sm')} ${avatarHtml(me, 'avatar-sm')}
<span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span> <span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span>
</a> </button>
<div class="nav-profile-dropdown" id="nav-profile-dropdown">
<a href="/profile.html">Voir profil</a>
<a href="/auth/logout">Se déconnecter</a> <a href="/auth/logout">Se déconnecter</a>
</div> </div>
</div> </div>
</div>
</div>
<div class="nav-controls"> <div class="nav-controls">
<button type="button" class="icon-btn secondary" id="theme-toggle" title="Changer de thème" aria-label="Changer de thème"></button> <button type="button" class="icon-btn secondary" id="theme-toggle" title="Changer de thème" aria-label="Changer de thème"></button>
<button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">&#9776;</button> <button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">&#9776;</button>
@ -53,6 +57,24 @@ async function initNav(activePage) {
toggle.addEventListener('click', () => menu.classList.toggle('open')); toggle.addEventListener('click', () => menu.classList.toggle('open'));
menu.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => menu.classList.remove('open'))); menu.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => menu.classList.remove('open')));
const profileTrigger = document.getElementById('nav-profile-trigger');
const profileDropdown = document.getElementById('nav-profile-dropdown');
function closeProfileDropdown() {
profileDropdown.classList.remove('open');
profileTrigger.setAttribute('aria-expanded', 'false');
}
profileTrigger.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = profileDropdown.classList.toggle('open');
profileTrigger.setAttribute('aria-expanded', String(isOpen));
});
document.addEventListener('click', (e) => {
if (!document.getElementById('nav-profile').contains(e.target)) closeProfileDropdown();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeProfileDropdown();
});
updateThemeToggleIcon(); updateThemeToggleIcon();
document.getElementById('theme-toggle').addEventListener('click', () => { document.getElementById('theme-toggle').addEventListener('click', () => {
applyTheme(currentTheme() === 'dark' ? 'light' : 'dark'); applyTheme(currentTheme() === 'dark' ? 'light' : 'dark');

View file

@ -0,0 +1,33 @@
DROP TABLE calendar_availability;
DROP TABLE calendar_feeds;
DROP TABLE calendar_people;
CREATE TABLE calendar_feeds (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT,
ics_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, ics_url)
);
CREATE INDEX idx_calendar_feeds_user_id ON calendar_feeds (user_id);
CREATE TABLE calendar_availability (
id SERIAL PRIMARY KEY,
slot_id INTEGER NOT NULL REFERENCES calendar_slots(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_available BOOLEAN NOT NULL DEFAULT true,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (slot_id, user_id)
);
CREATE INDEX idx_calendar_availability_slot_id ON calendar_availability (slot_id);
-- Centralizes "which users appear on the calendar" (anyone with >=1 feed) and
-- their color, so getPeople()/getSlots() stay in sync without duplicating the
-- palette-assignment logic in two places.
CREATE VIEW calendar_active_people AS
SELECT u.id, u.name,
(ARRAY['#4285f4','#ea4335','#fbbc05','#34a853','#a142f4','#24c1e0','#ff6d00','#795548'])
[((ROW_NUMBER() OVER (ORDER BY u.id) - 1) % 8) + 1] AS color
FROM users u
WHERE EXISTS (SELECT 1 FROM calendar_feeds f WHERE f.user_id = u.id);

View file

@ -1,39 +1,20 @@
const pool = require('../db/pool'); const pool = require('../db/pool');
const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates'); const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates');
const COLOR_PALETTE = [ // "People" on the calendar are just app users who have registered at least
'#4285f4', '#ea4335', '#fbbc05', '#34a853', // one feed — calendar_active_people (a view, see migration 011) centralizes
'#a142f4', '#24c1e0', '#ff6d00', '#795548', // that membership + a stable color per user, so this and getSlots() below
]; // can't disagree with each other.
async function getPeople() { async function getPeople() {
const { rows } = await pool.query('SELECT id, name, color FROM calendar_people ORDER BY id'); const { rows } = await pool.query('SELECT id, name, color FROM calendar_active_people ORDER BY id');
return rows; return rows;
} }
// Always called from within the ingestSlots transaction below (needs the
// same client so the color-index count and the insert see a consistent view).
async function upsertPerson(client, name) {
const existing = await client.query('SELECT id FROM calendar_people WHERE name = $1', [name]);
if (existing.rows[0]) return existing.rows[0].id;
const { rows: countRows } = await client.query('SELECT COUNT(*)::int AS count FROM calendar_people');
const color = COLOR_PALETTE[countRows[0].count % COLOR_PALETTE.length];
const { rows } = await client.query(
`INSERT INTO calendar_people (name, color) VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET name = excluded.name
RETURNING id`,
[name, color]
);
return rows[0].id;
}
// Returns ingestion counts so callers can surface a diagnostic (e.g. "12 // Returns ingestion counts so callers can surface a diagnostic (e.g. "12
// slots but 0 availability rows" points at a payload-shape mismatch from // slots but 0 availability rows" points at a payload-shape mismatch — a slot
// n8n, since a slot with no matching calendar_availability rows is silently // with no matching calendar_availability rows is silently excluded from
// excluded from getSlots() below — it would otherwise look like "nothing // getSlots() below, so it would otherwise look like "nothing happened" with
// happened" with no error anywhere. // no error anywhere.
async function ingestSlots(slots) { async function ingestSlots(slots) {
const client = await pool.connect(); const client = await pool.connect();
let availabilityRows = 0; let availabilityRows = 0;
@ -59,35 +40,27 @@ async function ingestSlots(slots) {
); );
const slotId = slotRows[0].id; const slotId = slotRows[0].id;
// people may arrive as a JSON string from n8n's Set-node serialization. const people = Array.isArray(slot.people) ? slot.people : [];
let people = typeof slot.people === 'string' ? JSON.parse(slot.people) : slot.people || [];
if (!Array.isArray(people)) people = [];
if (people.length === 0) { if (people.length === 0) {
slotsWithNoPeople += 1; slotsWithNoPeople += 1;
console.warn('[calendar] ingest: slot has no people entries', { console.warn('[calendar] ingest: slot has no people entries', { lower: slot.lower, upper: slot.upper });
lower: slot.lower,
upper: slot.upper,
rawPeopleType: typeof slot.people,
});
} }
for (const person of people) { for (const person of people) {
if (!person || !person.name) { if (!person || !person.userId) {
console.warn('[calendar] ingest: skipped a person entry with no "name" field', person); console.warn('[calendar] ingest: skipped a person entry with no "userId" field', person);
continue; continue;
} }
const personId = await upsertPerson(client, person.name);
const isAvailable = !!person.available; const isAvailable = !!person.available;
if (isAvailable) availableTrueCount += 1; if (isAvailable) availableTrueCount += 1;
else availableFalseCount += 1; else availableFalseCount += 1;
await client.query( await client.query(
`INSERT INTO calendar_availability (slot_id, person_id, is_available, checked_at) `INSERT INTO calendar_availability (slot_id, user_id, is_available, checked_at)
VALUES ($1, $2, $3, now()) VALUES ($1, $2, $3, now())
ON CONFLICT (slot_id, person_id) DO UPDATE SET ON CONFLICT (slot_id, user_id) DO UPDATE SET
is_available = excluded.is_available, is_available = excluded.is_available,
checked_at = excluded.checked_at`, checked_at = excluded.checked_at`,
[slotId, personId, isAvailable] [slotId, person.userId, isAvailable]
); );
availabilityRows += 1; availabilityRows += 1;
} }
@ -132,7 +105,7 @@ async function getSlots({ minPeople = 0, personIds = null, weeks = 3 } = {}) {
ORDER BY p.id ORDER BY p.id
) )
FROM calendar_availability sa2 FROM calendar_availability sa2
JOIN calendar_people p ON p.id = sa2.person_id JOIN calendar_active_people p ON p.id = sa2.user_id
WHERE sa2.slot_id = ts.id WHERE sa2.slot_id = ts.id
) AS people ) AS people
FROM calendar_slots ts FROM calendar_slots ts
@ -141,7 +114,7 @@ async function getSlots({ minPeople = 0, personIds = null, weeks = 3 } = {}) {
COUNT(*) FILTER (WHERE is_available) AS available_count, COUNT(*) FILTER (WHERE is_available) AS available_count,
COUNT(*) AS total_in_filter COUNT(*) AS total_in_filter
FROM calendar_availability FROM calendar_availability
WHERE ($3::int[] IS NULL OR person_id = ANY($3::int[])) WHERE ($3::int[] IS NULL OR user_id = ANY($3::int[]))
GROUP BY slot_id GROUP BY slot_id
) filtered ON filtered.slot_id = ts.id ) filtered ON filtered.slot_id = ts.id
WHERE ts.slot_date >= $1 AND ts.slot_date <= $2 WHERE ts.slot_date >= $1 AND ts.slot_date <= $2
@ -158,33 +131,33 @@ async function getLastChecked() {
return rows[0].ts; return rows[0].ts;
} }
// Every registered feed across every person, for the sync job — never // Every registered feed across every user, for the sync job — never returned
// returned to non-admin API consumers (see getPeople() above, which omits // to non-admin API consumers (see getPeople() above, which omits ics_url
// ics_url entirely). // entirely).
async function findAllFeeds() { async function findAllFeeds() {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT f.id, f.person_id, p.name AS person_name, f.label, f.ics_url SELECT f.id, f.user_id, u.name AS user_name, f.label, f.ics_url
FROM calendar_feeds f FROM calendar_feeds f
JOIN calendar_people p ON p.id = f.person_id JOIN users u ON u.id = f.user_id
ORDER BY f.person_id, f.id ORDER BY f.user_id, f.id
`); `);
return rows; return rows;
} }
async function findFeedsForPerson(personId) { async function findFeedsForUser(userId) {
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT id, person_id, label, ics_url FROM calendar_feeds WHERE person_id = $1 ORDER BY id', 'SELECT id, user_id, label, ics_url FROM calendar_feeds WHERE user_id = $1 ORDER BY id',
[personId] [userId]
); );
return rows; return rows;
} }
async function addFeed(personId, { label, icsUrl }) { async function addFeed(userId, { label, icsUrl }) {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO calendar_feeds (person_id, label, ics_url) `INSERT INTO calendar_feeds (user_id, label, ics_url)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, person_id, label, ics_url`, RETURNING id, user_id, label, ics_url`,
[personId, label || null, icsUrl] [userId, label || null, icsUrl]
); );
return rows[0]; return rows[0];
} }
@ -193,19 +166,22 @@ async function removeFeed(feedId) {
await pool.query('DELETE FROM calendar_feeds WHERE id = $1', [feedId]); await pool.query('DELETE FROM calendar_feeds WHERE id = $1', [feedId]);
} }
// Admin-only variant of getPeople(): includes each person's registered feeds // Every app user (not just ones already on the calendar) with their
// (label + ics_url) so an admin can review/edit them. The public getPeople() // registered feeds attached — lets an admin give someone their first feed,
// above deliberately never exposes ics_url — it's a secret, effectively // not just manage existing entries. The public getPeople() above deliberately
// granting calendar read access to whoever has it. // never exposes ics_url — it's a secret, effectively granting calendar read
async function getPeopleForAdmin() { // access to whoever has it.
const people = await getPeople(); async function getUsersWithFeeds() {
const { rows: users } = await pool.query(
'SELECT id, name, avatar_url, is_admin FROM users ORDER BY name'
);
const feeds = await findAllFeeds(); const feeds = await findAllFeeds();
const feedsByPerson = new Map(); const feedsByUser = new Map();
for (const feed of feeds) { for (const feed of feeds) {
if (!feedsByPerson.has(feed.person_id)) feedsByPerson.set(feed.person_id, []); if (!feedsByUser.has(feed.user_id)) feedsByUser.set(feed.user_id, []);
feedsByPerson.get(feed.person_id).push({ id: feed.id, label: feed.label, icsUrl: feed.ics_url }); feedsByUser.get(feed.user_id).push({ id: feed.id, label: feed.label, icsUrl: feed.ics_url });
} }
return people.map((p) => ({ ...p, feeds: feedsByPerson.get(p.id) || [] })); return users.map((u) => ({ ...u, feeds: feedsByUser.get(u.id) || [] }));
} }
// TIME columns come back from pg as 'HH:MM:SS' strings — split into numbers // TIME columns come back from pg as 'HH:MM:SS' strings — split into numbers
@ -248,10 +224,10 @@ module.exports = {
getSlots, getSlots,
getLastChecked, getLastChecked,
findAllFeeds, findAllFeeds,
findFeedsForPerson, findFeedsForUser,
addFeed, addFeed,
removeFeed, removeFeed,
getPeopleForAdmin, getUsersWithFeeds,
getSlotSettings, getSlotSettings,
updateSlotSettings, updateSlotSettings,
}; };

View file

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const calendarRepo = require('../repositories/calendarRepo'); const calendarRepo = require('../repositories/calendarRepo');
const usersRepo = require('../repositories/usersRepo');
const workflowState = require('../lib/calendarWorkflowState'); const workflowState = require('../lib/calendarWorkflowState');
const calendarSync = require('../services/calendarSync'); const calendarSync = require('../services/calendarSync');
const { requireAdmin } = require('../auth/middleware'); const { requireAdmin } = require('../auth/middleware');
@ -78,7 +79,16 @@ router.get(
'/people/admin', '/people/admin',
requireAdmin, requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
res.json(await calendarRepo.getPeopleForAdmin()); 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,
}))
);
}) })
); );
@ -90,11 +100,16 @@ router.post(
if (!icsUrl || !icsUrl.trim()) { if (!icsUrl || !icsUrl.trim()) {
return res.status(400).json({ error: 'ics_url_required' }); return res.status(400).json({ error: 'ics_url_required' });
} }
const feed = await calendarRepo.addFeed(parseInt(req.params.id, 10), { 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, {
label: label ? label.trim() : null, label: label ? label.trim() : null,
icsUrl: icsUrl.trim(), icsUrl: icsUrl.trim(),
}); });
res.status(201).json({ id: feed.id, personId: feed.person_id, label: feed.label, icsUrl: feed.ics_url }); res.status(201).json({ id: feed.id, userId: feed.user_id, label: feed.label, icsUrl: feed.ics_url });
}) })
); );

View file

@ -73,24 +73,24 @@ function dateOnlyToParisSpan(dateOnly) {
async function syncAvailability() { async function syncAvailability() {
const [feeds, slotConfig] = await Promise.all([calendarRepo.findAllFeeds(), calendarRepo.getSlotSettings()]); const [feeds, slotConfig] = await Promise.all([calendarRepo.findAllFeeds(), calendarRepo.getSlotSettings()]);
const byPerson = new Map(); const byUser = new Map();
for (const feed of feeds) { for (const feed of feeds) {
if (!byPerson.has(feed.person_id)) byPerson.set(feed.person_id, { name: feed.person_name }); if (!byUser.has(feed.user_id)) byUser.set(feed.user_id, { name: feed.user_name });
} }
const results = await Promise.allSettled(feeds.map((feed) => fetchBusyIntervals(feed.ics_url))); const results = await Promise.allSettled(feeds.map((feed) => fetchBusyIntervals(feed.ics_url)));
const intervalsByPerson = new Map(); const intervalsByUser = new Map();
let failedFeeds = 0; let failedFeeds = 0;
results.forEach((result, i) => { results.forEach((result, i) => {
const feed = feeds[i]; const feed = feeds[i];
if (result.status === 'rejected') { if (result.status === 'rejected') {
failedFeeds += 1; failedFeeds += 1;
console.warn(`[calendar] failed to fetch feed id=${feed.id} (person id=${feed.person_id}): ${result.reason.message}`); console.warn(`[calendar] failed to fetch feed id=${feed.id} (user id=${feed.user_id}): ${result.reason.message}`);
return; return;
} }
const existing = intervalsByPerson.get(feed.person_id) || []; const existing = intervalsByUser.get(feed.user_id) || [];
intervalsByPerson.set(feed.person_id, existing.concat(result.value)); intervalsByUser.set(feed.user_id, existing.concat(result.value));
}); });
const slots = generateSlots(3, slotConfig); const slots = generateSlots(3, slotConfig);
@ -100,10 +100,10 @@ async function syncAvailability() {
// shown on /calendar.html, stays exactly what the admin configured. // shown on /calendar.html, stays exactly what the admin configured.
const { lower: checkLower, upper: checkUpper } = widenWindow(slot.lower, slot.upper, slotConfig.marginMinutes); const { lower: checkLower, upper: checkUpper } = widenWindow(slot.lower, slot.upper, slotConfig.marginMinutes);
const people = []; const people = [];
for (const [personId, info] of byPerson) { for (const [userId] of byUser) {
if (!intervalsByPerson.has(personId)) continue; // every feed for this person failed this run if (!intervalsByUser.has(userId)) continue; // every feed for this user failed this run
const busy = isBusyDuring(intervalsByPerson.get(personId), checkLower, checkUpper); const busy = isBusyDuring(intervalsByUser.get(userId), checkLower, checkUpper);
people.push({ name: info.name, available: !busy }); people.push({ userId, available: !busy });
} }
return { lower: slot.lower.toISOString(), upper: slot.upper.toISOString(), people }; return { lower: slot.lower.toISOString(), upper: slot.upper.toISOString(), people };
}); });