-
-
${escapeHtml(person.name)}
+ ${avatarHtml(user, 'avatar-sm')}
+
${escapeHtml(user.name)}${user.isAdmin ? ' admin' : ''}
- ${person.feeds.length ? person.feeds.map(feedRowTemplate).join('') : '
Aucun calendrier configuré.
'}
-
@@ -53,6 +57,24 @@ async function initNav(activePage) {
toggle.addEventListener('click', () => menu.classList.toggle('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();
document.getElementById('theme-toggle').addEventListener('click', () => {
applyTheme(currentTheme() === 'dark' ? 'light' : 'dark');
diff --git a/src/db/migrations/011_calendar_users.sql b/src/db/migrations/011_calendar_users.sql
new file mode 100644
index 0000000..44e2f61
--- /dev/null
+++ b/src/db/migrations/011_calendar_users.sql
@@ -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);
diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js
index 4c3969c..10e8e5a 100644
--- a/src/repositories/calendarRepo.js
+++ b/src/repositories/calendarRepo.js
@@ -1,39 +1,20 @@
const pool = require('../db/pool');
const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates');
-const COLOR_PALETTE = [
- '#4285f4', '#ea4335', '#fbbc05', '#34a853',
- '#a142f4', '#24c1e0', '#ff6d00', '#795548',
-];
-
+// "People" on the calendar are just app users who have registered at least
+// one feed — calendar_active_people (a view, see migration 011) centralizes
+// that membership + a stable color per user, so this and getSlots() below
+// can't disagree with each other.
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;
}
-// 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
-// slots but 0 availability rows" points at a payload-shape mismatch from
-// n8n, since a slot with no matching calendar_availability rows is silently
-// excluded from getSlots() below — it would otherwise look like "nothing
-// happened" with no error anywhere.
+// slots but 0 availability rows" points at a payload-shape mismatch — a slot
+// with no matching calendar_availability rows is silently excluded from
+// getSlots() below, so it would otherwise look like "nothing happened" with
+// no error anywhere.
async function ingestSlots(slots) {
const client = await pool.connect();
let availabilityRows = 0;
@@ -59,35 +40,27 @@ async function ingestSlots(slots) {
);
const slotId = slotRows[0].id;
- // people may arrive as a JSON string from n8n's Set-node serialization.
- let people = typeof slot.people === 'string' ? JSON.parse(slot.people) : slot.people || [];
- if (!Array.isArray(people)) people = [];
-
+ const people = Array.isArray(slot.people) ? slot.people : [];
if (people.length === 0) {
slotsWithNoPeople += 1;
- console.warn('[calendar] ingest: slot has no people entries', {
- lower: slot.lower,
- upper: slot.upper,
- rawPeopleType: typeof slot.people,
- });
+ console.warn('[calendar] ingest: slot has no people entries', { lower: slot.lower, upper: slot.upper });
}
for (const person of people) {
- if (!person || !person.name) {
- console.warn('[calendar] ingest: skipped a person entry with no "name" field', person);
+ if (!person || !person.userId) {
+ console.warn('[calendar] ingest: skipped a person entry with no "userId" field', person);
continue;
}
- const personId = await upsertPerson(client, person.name);
const isAvailable = !!person.available;
if (isAvailable) availableTrueCount += 1;
else availableFalseCount += 1;
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())
- ON CONFLICT (slot_id, person_id) DO UPDATE SET
+ ON CONFLICT (slot_id, user_id) DO UPDATE SET
is_available = excluded.is_available,
checked_at = excluded.checked_at`,
- [slotId, personId, isAvailable]
+ [slotId, person.userId, isAvailable]
);
availabilityRows += 1;
}
@@ -132,7 +105,7 @@ async function getSlots({ minPeople = 0, personIds = null, weeks = 3 } = {}) {
ORDER BY p.id
)
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
) AS people
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(*) AS total_in_filter
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
) filtered ON filtered.slot_id = ts.id
WHERE ts.slot_date >= $1 AND ts.slot_date <= $2
@@ -158,33 +131,33 @@ async function getLastChecked() {
return rows[0].ts;
}
-// Every registered feed across every person, for the sync job — never
-// returned to non-admin API consumers (see getPeople() above, which omits
-// ics_url entirely).
+// Every registered feed across every user, for the sync job — never returned
+// to non-admin API consumers (see getPeople() above, which omits ics_url
+// entirely).
async function findAllFeeds() {
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
- JOIN calendar_people p ON p.id = f.person_id
- ORDER BY f.person_id, f.id
+ JOIN users u ON u.id = f.user_id
+ ORDER BY f.user_id, f.id
`);
return rows;
}
-async function findFeedsForPerson(personId) {
+async function findFeedsForUser(userId) {
const { rows } = await pool.query(
- 'SELECT id, person_id, label, ics_url FROM calendar_feeds WHERE person_id = $1 ORDER BY id',
- [personId]
+ 'SELECT id, user_id, label, ics_url FROM calendar_feeds WHERE user_id = $1 ORDER BY id',
+ [userId]
);
return rows;
}
-async function addFeed(personId, { label, icsUrl }) {
+async function addFeed(userId, { label, icsUrl }) {
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)
- RETURNING id, person_id, label, ics_url`,
- [personId, label || null, icsUrl]
+ RETURNING id, user_id, label, ics_url`,
+ [userId, label || null, icsUrl]
);
return rows[0];
}
@@ -193,19 +166,22 @@ async function removeFeed(feedId) {
await pool.query('DELETE FROM calendar_feeds WHERE id = $1', [feedId]);
}
-// Admin-only variant of getPeople(): includes each person's registered feeds
-// (label + ics_url) so an admin can review/edit them. The public getPeople()
-// above deliberately never exposes ics_url — it's a secret, effectively
-// granting calendar read access to whoever has it.
-async function getPeopleForAdmin() {
- const people = await getPeople();
+// Every app user (not just ones already on the calendar) with their
+// registered feeds attached — lets an admin give someone their first feed,
+// not just manage existing entries. The public getPeople() above deliberately
+// never exposes ics_url — it's a secret, effectively granting calendar read
+// access to whoever has it.
+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 feedsByPerson = new Map();
+ const feedsByUser = new Map();
for (const feed of feeds) {
- if (!feedsByPerson.has(feed.person_id)) feedsByPerson.set(feed.person_id, []);
- feedsByPerson.get(feed.person_id).push({ id: feed.id, label: feed.label, icsUrl: feed.ics_url });
+ if (!feedsByUser.has(feed.user_id)) feedsByUser.set(feed.user_id, []);
+ 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
@@ -248,10 +224,10 @@ module.exports = {
getSlots,
getLastChecked,
findAllFeeds,
- findFeedsForPerson,
+ findFeedsForUser,
addFeed,
removeFeed,
- getPeopleForAdmin,
+ getUsersWithFeeds,
getSlotSettings,
updateSlotSettings,
};
diff --git a/src/routes/calendar.js b/src/routes/calendar.js
index 240b251..a57a88d 100644
--- a/src/routes/calendar.js
+++ b/src/routes/calendar.js
@@ -1,5 +1,6 @@
const express = require('express');
const calendarRepo = require('../repositories/calendarRepo');
+const usersRepo = require('../repositories/usersRepo');
const workflowState = require('../lib/calendarWorkflowState');
const calendarSync = require('../services/calendarSync');
const { requireAdmin } = require('../auth/middleware');
@@ -78,7 +79,16 @@ router.get(
'/people/admin',
requireAdmin,
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()) {
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,
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 });
})
);
diff --git a/src/services/calendarSync.js b/src/services/calendarSync.js
index 9395b54..d109113 100644
--- a/src/services/calendarSync.js
+++ b/src/services/calendarSync.js
@@ -73,24 +73,24 @@ function dateOnlyToParisSpan(dateOnly) {
async function syncAvailability() {
const [feeds, slotConfig] = await Promise.all([calendarRepo.findAllFeeds(), calendarRepo.getSlotSettings()]);
- const byPerson = new Map();
+ const byUser = new Map();
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 intervalsByPerson = new Map();
+ const intervalsByUser = new Map();
let failedFeeds = 0;
results.forEach((result, i) => {
const feed = feeds[i];
if (result.status === 'rejected') {
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;
}
- const existing = intervalsByPerson.get(feed.person_id) || [];
- intervalsByPerson.set(feed.person_id, existing.concat(result.value));
+ const existing = intervalsByUser.get(feed.user_id) || [];
+ intervalsByUser.set(feed.user_id, existing.concat(result.value));
});
const slots = generateSlots(3, slotConfig);
@@ -100,10 +100,10 @@ async function syncAvailability() {
// shown on /calendar.html, stays exactly what the admin configured.
const { lower: checkLower, upper: checkUpper } = widenWindow(slot.lower, slot.upper, slotConfig.marginMinutes);
const people = [];
- for (const [personId, info] of byPerson) {
- if (!intervalsByPerson.has(personId)) continue; // every feed for this person failed this run
- const busy = isBusyDuring(intervalsByPerson.get(personId), checkLower, checkUpper);
- people.push({ name: info.name, available: !busy });
+ for (const [userId] of byUser) {
+ if (!intervalsByUser.has(userId)) continue; // every feed for this user failed this run
+ const busy = isBusyDuring(intervalsByUser.get(userId), checkLower, checkUpper);
+ people.push({ userId, available: !busy });
}
return { lower: slot.lower.toISOString(), upper: slot.upper.toISOString(), people };
});