mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: ics utl verification, custom in app name
This commit is contained in:
parent
d296494d0e
commit
b46c727c85
9 changed files with 149 additions and 23 deletions
5
src/db/migrations/012_user_display_name.sql
Normal file
5
src/db/migrations/012_user_display_name.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE users ADD COLUMN authentik_name TEXT;
|
||||
UPDATE users SET authentik_name = name;
|
||||
ALTER TABLE users ALTER COLUMN authentik_name SET NOT NULL;
|
||||
|
||||
ALTER TABLE users ADD COLUMN display_name TEXT;
|
||||
|
|
@ -1,24 +1,49 @@
|
|||
const pool = require('../db/pool');
|
||||
|
||||
const PROFILE_FIELDS = 'id, authentik_sub, name, username, email, avatar_url, groups, is_admin, created_at';
|
||||
const PROFILE_FIELDS =
|
||||
'id, authentik_sub, name, display_name, username, email, avatar_url, groups, is_admin, created_at';
|
||||
|
||||
async function findById(id) {
|
||||
const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// `name` is always the effective display name (what every other join in the
|
||||
// app already reads via `u.name`) — it tracks the Authentik-provided name
|
||||
// unless the user has set a display_name override, in which case that takes
|
||||
// precedence and survives future logins even if their Authentik name changes.
|
||||
async function upsertFromClaims({ sub, name, username, email, avatarUrl, groups, isAdmin }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (authentik_sub, name, username, email, avatar_url, groups, is_admin)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO users (authentik_sub, name, authentik_name, username, email, avatar_url, groups, is_admin)
|
||||
VALUES ($1, $2, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (authentik_sub)
|
||||
DO UPDATE SET name = $2, username = $3, email = $4, avatar_url = $5, groups = $6, is_admin = $7, updated_at = now()
|
||||
DO UPDATE SET
|
||||
authentik_name = $2,
|
||||
name = COALESCE(users.display_name, $2),
|
||||
username = $3, email = $4, avatar_url = $5, groups = $6, is_admin = $7, updated_at = now()
|
||||
RETURNING ${PROFILE_FIELDS}`,
|
||||
[sub, name, username || null, email, avatarUrl || null, groups || [], isAdmin]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Sets (or, if displayName is empty, clears) the user's display-name
|
||||
// override. Also updates `name` immediately so every other page reflects the
|
||||
// change right away, without waiting for the next login.
|
||||
async function updateDisplayName(userId, displayName) {
|
||||
const trimmed = displayName ? displayName.trim() : '';
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users
|
||||
SET display_name = NULLIF($2, ''),
|
||||
name = COALESCE(NULLIF($2, ''), authentik_name),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING ${PROFILE_FIELDS}`,
|
||||
[userId, trimmed]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function getActivityStats(userId) {
|
||||
const [songs, suggestions, votes] = await Promise.all([
|
||||
pool.query('SELECT COUNT(*)::int AS count FROM songs WHERE added_by = $1', [userId]),
|
||||
|
|
@ -47,4 +72,4 @@ async function findAllWithActivity() {
|
|||
return rows;
|
||||
}
|
||||
|
||||
module.exports = { findById, upsertFromClaims, getActivityStats, findAllWithActivity };
|
||||
module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity };
|
||||
|
|
|
|||
|
|
@ -93,9 +93,15 @@ router.post(
|
|||
if (!icsUrl || !icsUrl.trim()) {
|
||||
return res.status(400).json({ error: 'ics_url_required' });
|
||||
}
|
||||
const trimmedUrl = icsUrl.trim();
|
||||
try {
|
||||
await calendarSync.testFeed(trimmedUrl);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'ics_url_unreachable', message: err.message });
|
||||
}
|
||||
const feed = await calendarRepo.addFeed(req.user.id, {
|
||||
label: label ? label.trim() : null,
|
||||
icsUrl: icsUrl.trim(),
|
||||
icsUrl: trimmedUrl,
|
||||
});
|
||||
res.status(201).json({ id: feed.id, label: feed.label, icsUrl: feed.ics_url });
|
||||
})
|
||||
|
|
@ -142,9 +148,15 @@ router.post(
|
|||
if (!user) {
|
||||
return res.status(404).json({ error: 'user_not_found' });
|
||||
}
|
||||
const trimmedUrl = icsUrl.trim();
|
||||
try {
|
||||
await calendarSync.testFeed(trimmedUrl);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'ics_url_unreachable', message: err.message });
|
||||
}
|
||||
const feed = await calendarRepo.addFeed(userId, {
|
||||
label: label ? label.trim() : null,
|
||||
icsUrl: icsUrl.trim(),
|
||||
icsUrl: trimmedUrl,
|
||||
});
|
||||
res.status(201).json({ id: feed.id, userId: feed.user_id, label: feed.label, icsUrl: feed.ics_url });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ function authentikAccountUrl() {
|
|||
router.get(
|
||||
'/me',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id, name, username, email, avatar_url, is_admin } = req.user;
|
||||
const { id, name, display_name, username, email, avatar_url, is_admin } = req.user;
|
||||
res.json({
|
||||
id,
|
||||
name,
|
||||
hasCustomName: !!display_name,
|
||||
username,
|
||||
email,
|
||||
avatarUrl: avatar_url,
|
||||
|
|
@ -29,11 +30,12 @@ router.get(
|
|||
router.get(
|
||||
'/me/profile',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id, name, username, email, avatar_url, groups, is_admin, created_at } = req.user;
|
||||
const { id, name, display_name, username, email, avatar_url, groups, is_admin, created_at } = req.user;
|
||||
const stats = await usersRepo.getActivityStats(id);
|
||||
res.json({
|
||||
id,
|
||||
name,
|
||||
hasCustomName: !!display_name,
|
||||
username,
|
||||
email,
|
||||
avatarUrl: avatar_url,
|
||||
|
|
@ -46,4 +48,16 @@ router.get(
|
|||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/me/display-name',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { displayName } = req.body || {};
|
||||
if (displayName && displayName.trim().length > 60) {
|
||||
return res.status(400).json({ error: 'display_name_too_long' });
|
||||
}
|
||||
const user = await usersRepo.updateDisplayName(req.user.id, displayName || '');
|
||||
res.json({ name: user.name, hasCustomName: !!user.display_name });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@ const { parisWallClockToUTC } = require('../lib/calendarDates');
|
|||
const RANGE_DAYS = 22; // slightly more than the 3 weeks generateSlots() covers
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
// Fetches and parses one ICS feed, immediately reducing every VEVENT down to
|
||||
// {start, end} — nothing else (title, description, location, attendees) is
|
||||
// ever read out of the parsed calendar, so it can't end up logged, stored, or
|
||||
// returned by an API by mistake. This holds regardless of whether the
|
||||
// provider's feed itself is busy/free-only or full-detail.
|
||||
async function fetchBusyIntervals(icsUrl) {
|
||||
// Fetches one ICS feed and parses it into node-ical's raw component map.
|
||||
// Shared by fetchBusyIntervals (real sync) and testFeed (add-time validation).
|
||||
async function fetchIcsCalendar(icsUrl) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
let text;
|
||||
|
|
@ -22,8 +19,16 @@ async function fetchBusyIntervals(icsUrl) {
|
|||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
return ical.parseICS(text);
|
||||
}
|
||||
|
||||
const parsed = ical.parseICS(text);
|
||||
// Fetches and parses one ICS feed, immediately reducing every VEVENT down to
|
||||
// {start, end} — nothing else (title, description, location, attendees) is
|
||||
// ever read out of the parsed calendar, so it can't end up logged, stored, or
|
||||
// returned by an API by mistake. This holds regardless of whether the
|
||||
// provider's feed itself is busy/free-only or full-detail.
|
||||
async function fetchBusyIntervals(icsUrl) {
|
||||
const parsed = await fetchIcsCalendar(icsUrl);
|
||||
const rangeFrom = new Date();
|
||||
const rangeTo = new Date(rangeFrom.getTime() + RANGE_DAYS * 86400000);
|
||||
|
||||
|
|
@ -44,6 +49,21 @@ async function fetchBusyIntervals(icsUrl) {
|
|||
return intervals;
|
||||
}
|
||||
|
||||
// Validates a feed URL when a user adds it — catches the exact mistake that
|
||||
// motivated this: a URL that "succeeds" (200 OK) but isn't actually that
|
||||
// person's calendar (e.g. an HTML page, wrong link) parses to zero components
|
||||
// and would otherwise silently sit there doing nothing until the next sync.
|
||||
// Returns { eventCount } on success; throws a user-facing message otherwise.
|
||||
async function testFeed(icsUrl) {
|
||||
const parsed = await fetchIcsCalendar(icsUrl);
|
||||
const componentCount = Object.keys(parsed).length;
|
||||
if (componentCount === 0) {
|
||||
throw new Error("Aucune donnée de calendrier trouvée à cette adresse — vérifiez le lien.");
|
||||
}
|
||||
const eventCount = Object.values(parsed).filter((c) => c && c.type === 'VEVENT').length;
|
||||
return { eventCount };
|
||||
}
|
||||
|
||||
function toInterval({ start, end, datetype, isFullDay }) {
|
||||
if (datetype === 'date' || isFullDay) {
|
||||
// node-ical builds date-only (VALUE=DATE) values with the server's local
|
||||
|
|
@ -91,10 +111,6 @@ async function syncAvailability() {
|
|||
}
|
||||
const existing = intervalsByUser.get(feed.user_id) || [];
|
||||
intervalsByUser.set(feed.user_id, existing.concat(result.value));
|
||||
console.log(
|
||||
`[calendar] feed id=${feed.id} (user id=${feed.user_id}): ${result.value.length} busy interval(s) — ` +
|
||||
result.value.slice(0, 30).map((iv) => `${iv.start.toISOString()}->${iv.end.toISOString()}`).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
const slots = generateSlots(3, slotConfig);
|
||||
|
|
@ -116,4 +132,4 @@ async function syncAvailability() {
|
|||
return { ...summary, failedFeeds };
|
||||
}
|
||||
|
||||
module.exports = { syncAvailability, fetchBusyIntervals };
|
||||
module.exports = { syncAvailability, fetchBusyIntervals, testFeed };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue