mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
fix: delete n8n old calendar to keep only cal one
This commit is contained in:
parent
63fb6cd45a
commit
6de6cfacf2
9 changed files with 220 additions and 131 deletions
|
|
@ -313,7 +313,7 @@ La garantie de confidentialité ne repose donc pas sur le fournisseur, mais sur
|
|||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -180,21 +180,55 @@ nav#main-nav {
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-user > a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
.nav-profile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-profile-link {
|
||||
.nav-profile-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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-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 {
|
||||
border-radius: 999px;
|
||||
|
|
@ -1068,16 +1102,31 @@ a.back-link:hover { color: var(--accent); }
|
|||
|
||||
.nav-user {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.nav-user > a,
|
||||
.nav-user > .nav-profile-link {
|
||||
.nav-profile-trigger {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
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; }
|
||||
|
|
@ -1330,13 +1379,6 @@ a.back-link:hover { color: var(--accent); }
|
|||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.calendar-color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-feeds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -56,16 +56,16 @@ function feedRowTemplate(feed) {
|
|||
`;
|
||||
}
|
||||
|
||||
function calendarPersonRowTemplate(person) {
|
||||
function calendarUserRowTemplate(user) {
|
||||
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">
|
||||
<span class="calendar-color-dot" style="background:${escapeHtml(person.color)}"></span>
|
||||
<div class="card-title">${escapeHtml(person.name)}</div>
|
||||
${avatarHtml(user, 'avatar-sm')}
|
||||
<div class="card-title">${escapeHtml(user.name)}${user.isAdmin ? ' <span class="badge">admin</span>' : ''}</div>
|
||||
</div>
|
||||
<div class="calendar-feeds">
|
||||
${person.feeds.length ? person.feeds.map(feedRowTemplate).join('') : '<p class="empty">Aucun calendrier configuré.</p>'}
|
||||
<form class="inline-form add-feed-form" data-person-id="${person.id}">
|
||||
${user.feeds.length ? user.feeds.map(feedRowTemplate).join('') : '<p class="empty">Aucun calendrier configuré.</p>'}
|
||||
<form class="inline-form add-feed-form" data-user-id="${user.id}">
|
||||
<input name="label" placeholder="Libellé (optionnel)">
|
||||
<input name="icsUrl" type="url" placeholder="URL du calendrier (.ics)" required>
|
||||
<button type="submit" class="secondary icon-btn">+ Ajouter</button>
|
||||
|
|
@ -75,29 +75,29 @@ function calendarPersonRowTemplate(person) {
|
|||
`;
|
||||
}
|
||||
|
||||
async function loadCalendarPeople() {
|
||||
const people = await api.get('/api/calendar/people/admin');
|
||||
async function loadCalendarUsers() {
|
||||
const users = await api.get('/api/calendar/people/admin');
|
||||
const container = document.getElementById('calendar-people-list');
|
||||
container.innerHTML = people.length
|
||||
? people.map(calendarPersonRowTemplate).join('')
|
||||
: '<p class="empty">Aucune personne suivie pour le moment.</p>';
|
||||
container.innerHTML = users.length
|
||||
? users.map(calendarUserRowTemplate).join('')
|
||||
: '<p class="empty">Aucun utilisateur pour le moment.</p>';
|
||||
|
||||
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) => {
|
||||
btn.addEventListener('click', () => onRemoveFeed(parseInt(btn.dataset.feedId, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
async function onAddFeed(e, personId) {
|
||||
async function onAddFeed(e, userId) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const label = form.label.value.trim();
|
||||
const icsUrl = form.icsUrl.value.trim();
|
||||
try {
|
||||
await api.post(`/api/calendar/people/${personId}/feeds`, { label, icsUrl });
|
||||
await loadCalendarPeople();
|
||||
await api.post(`/api/calendar/people/${userId}/feeds`, { label, icsUrl });
|
||||
await loadCalendarUsers();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ async function onAddFeed(e, personId) {
|
|||
async function onRemoveFeed(feedId) {
|
||||
try {
|
||||
await api.del(`/api/calendar/feeds/${feedId}`);
|
||||
await loadCalendarPeople();
|
||||
await loadCalendarUsers();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -197,9 +197,10 @@ async function onSaveSlotSettings(e) {
|
|||
|
||||
<h2>Calendriers des membres</h2>
|
||||
<p class="note">
|
||||
Chaque personne peut avoir plusieurs calendriers (Google, Outlook, Apple...). L'application ne
|
||||
conserve jamais le contenu de ces calendriers — seul un statut disponible/occupé par créneau est
|
||||
déduit et enregistré.
|
||||
Chaque utilisateur de l'application peut avoir plusieurs calendriers (Google, Outlook, Apple...).
|
||||
Seuls les utilisateurs avec au moins un calendrier configuré apparaissent sur `/calendar.html`.
|
||||
L'application ne conserve jamais le contenu de ces calendriers — seul un statut disponible/occupé
|
||||
par créneau est déduit et enregistré.
|
||||
</p>
|
||||
<div class="panel admin-user-list" id="calendar-people-list">
|
||||
<p class="empty">Chargement…</p>
|
||||
|
|
@ -207,7 +208,7 @@ async function onSaveSlotSettings(e) {
|
|||
`;
|
||||
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
|
||||
await loadSlotSettingsForm();
|
||||
await loadCalendarPeople();
|
||||
await loadCalendarUsers();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,14 +177,14 @@ function buildFilters() {
|
|||
const btn = document.getElementById('btn-refresh');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Actualisation…';
|
||||
showToast('Déclenchement du workflow n8n…');
|
||||
showToast('Synchronisation des calendriers…');
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
const message = err.message === 'n8n_not_configured'
|
||||
? "L'actualisation automatique n'est pas configurée (n8n)."
|
||||
const message = err.message === 'no_feeds_configured'
|
||||
? "Aucun calendrier n'est configuré — voir la page Administration."
|
||||
: err.message;
|
||||
showToast(message, true);
|
||||
resetRefreshButton(btn);
|
||||
|
|
|
|||
|
|
@ -34,12 +34,16 @@ async function initNav(activePage) {
|
|||
${me.isAdmin ? `<a href="/admin.html" class="${activePage === 'admin' ? 'active' : ''}">Administration</a>` : ''}
|
||||
</div>
|
||||
<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>` : ''}
|
||||
<a class="nav-profile-link" href="/profile.html">
|
||||
${avatarHtml(me, 'avatar-sm')}
|
||||
<span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span>
|
||||
</a>
|
||||
<a href="/auth/logout">Se déconnecter</a>
|
||||
<div class="nav-profile" id="nav-profile">
|
||||
<button type="button" class="nav-profile-trigger" id="nav-profile-trigger" aria-haspopup="true" aria-expanded="false">
|
||||
${avatarHtml(me, 'avatar-sm')}
|
||||
<span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-controls">
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
33
src/db/migrations/011_calendar_users.sql
Normal file
33
src/db/migrations/011_calendar_users.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue