mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: add calendar to app
This commit is contained in:
parent
b17715eaff
commit
08717c24bc
29 changed files with 1478 additions and 313 deletions
|
|
@ -4,6 +4,7 @@ const sessionMiddleware = require('./auth/session');
|
|||
const authRoutes = require('./auth/routes');
|
||||
const { attachUser, requireAuth } = require('./auth/middleware');
|
||||
const apiRouter = require('./routes');
|
||||
const calendarWebhooksRouter = require('./routes/calendarWebhooks');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
|
@ -19,6 +20,12 @@ function createApp() {
|
|||
app.use('/auth', authRoutes);
|
||||
app.use(attachUser);
|
||||
|
||||
// Called by n8n directly (server-to-server, no session) — secret-protected
|
||||
// per-route inside the router itself, not gated by requireAuth. Must be
|
||||
// mounted before the /api requireAuth line so unmatched paths (e.g.
|
||||
// GET /api/calendar/people) fall through to the normal session-gated router.
|
||||
app.use('/api/calendar', calendarWebhooksRouter);
|
||||
|
||||
app.use('/api', requireAuth, apiRouter);
|
||||
app.use(requireAuth, express.static(path.join(__dirname, '../public')));
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ async function attachUser(req, res, next) {
|
|||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.user) {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
// req.path is relative to the mount prefix here (Express strips '/api'
|
||||
// for every layer registered via app.use('/api', ...)), so it never
|
||||
// actually starts with '/api/' — use req.originalUrl, which always holds
|
||||
// the full request path regardless of mount nesting.
|
||||
if (req.originalUrl.startsWith('/api/')) {
|
||||
return res.status(401).json({ error: 'unauthenticated' });
|
||||
}
|
||||
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
|
||||
|
|
|
|||
|
|
@ -49,4 +49,13 @@ module.exports = {
|
|||
spotifyClientId: process.env.SPOTIFY_CLIENT_ID || null,
|
||||
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || null,
|
||||
youtubeApiKey: process.env.YOUTUBE_API_KEY || null,
|
||||
// Calendar (availability) feature. N8N_* are optional: without them,
|
||||
// viewing already-ingested availability still works, only the "refresh"
|
||||
// button (which triggers the n8n workflow) is disabled.
|
||||
n8nWebhookUrl: process.env.N8N_WEBHOOK_URL || null,
|
||||
n8nWebhookUser: process.env.N8N_WEBHOOK_USER || null,
|
||||
n8nWebhookPass: process.env.N8N_WEBHOOK_PASS || null,
|
||||
// Required: protects the two endpoints n8n calls directly (server-to-server,
|
||||
// no browser session involved) — see src/lib/calendarWebhookAuth.js.
|
||||
calendarWebhookSecret: required('CALENDAR_WEBHOOK_SECRET'),
|
||||
};
|
||||
|
|
|
|||
26
src/db/migrations/005_calendar.sql
Normal file
26
src/db/migrations/005_calendar.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE calendar_people (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL DEFAULT '#4285f4'
|
||||
);
|
||||
|
||||
CREATE TABLE calendar_slots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lower TIMESTAMPTZ NOT NULL,
|
||||
upper TIMESTAMPTZ NOT NULL,
|
||||
slot_date DATE NOT NULL,
|
||||
day_of_week SMALLINT NOT NULL,
|
||||
UNIQUE (lower, upper)
|
||||
);
|
||||
|
||||
CREATE TABLE calendar_availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slot_id INTEGER NOT NULL REFERENCES calendar_slots(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES calendar_people(id) ON DELETE CASCADE,
|
||||
is_available BOOLEAN NOT NULL DEFAULT true,
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (slot_id, person_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_calendar_slots_slot_date ON calendar_slots (slot_date);
|
||||
CREATE INDEX idx_calendar_availability_slot_id ON calendar_availability (slot_id);
|
||||
7
src/db/migrations/006_calendar_seed_people.sql
Normal file
7
src/db/migrations/006_calendar_seed_people.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
INSERT INTO calendar_people (name, color) VALUES
|
||||
('Nathan', '#4285f4'),
|
||||
('Raphaël', '#fbbc05'),
|
||||
('Yann', '#34a853'),
|
||||
('Jules', '#a142f4'),
|
||||
('AK', '#24c1e0')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
1
src/db/migrations/007_suggestion_spotify.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE suggestions ADD COLUMN IF NOT EXISTS spotify_url TEXT;
|
||||
30
src/lib/calendarDates.js
Normal file
30
src/lib/calendarDates.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Truncates to minute precision so repeated ingests of "the same" slot land
|
||||
// on the same instant even though n8n's timestamps carry sub-minute noise
|
||||
// that differs run to run (the DB's UNIQUE(lower, upper) constraint depends
|
||||
// on this). Returns a Date (not a string) — pg accepts Date directly for a
|
||||
// timestamptz column.
|
||||
function normalizeISO(iso) {
|
||||
const d = new Date(iso);
|
||||
d.setSeconds(0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Calendar date (YYYY-MM-DD) of the given instant in Europe/Paris, so e.g. a
|
||||
// 18:30 CEST slot stays on the correct local day regardless of what UTC day
|
||||
// it falls on.
|
||||
function slotDateParis(iso) {
|
||||
return new Date(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/Paris' });
|
||||
}
|
||||
|
||||
const WEEKDAY_TO_NUMBER = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
||||
|
||||
// Monday=1..Sunday=7, from the Europe/Paris-local weekday name. Falls back
|
||||
// to the instant's own (UTC) getDay() if the locale parsing ever fails.
|
||||
function dayOfWeekParis(iso) {
|
||||
const date = new Date(iso);
|
||||
const short = date.toLocaleDateString('en-US', { timeZone: 'Europe/Paris', weekday: 'short' }).slice(0, 3);
|
||||
const dow = WEEKDAY_TO_NUMBER[short];
|
||||
return dow === undefined ? date.getDay() || 7 : dow;
|
||||
}
|
||||
|
||||
module.exports = { normalizeISO, slotDateParis, dayOfWeekParis };
|
||||
23
src/lib/calendarWebhookAuth.js
Normal file
23
src/lib/calendarWebhookAuth.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
|
||||
// Protects the two endpoints n8n calls directly (server-to-server, no
|
||||
// browser session available) — a shared secret header instead of the
|
||||
// session-based auth the rest of /api uses. Constant-time compare since
|
||||
// this is a bearer-secret check.
|
||||
function calendarWebhookAuth(req, res, next) {
|
||||
const provided = req.header('X-Calendar-Webhook-Secret') || '';
|
||||
const expected = config.calendarWebhookSecret;
|
||||
|
||||
const providedBuf = Buffer.from(provided);
|
||||
const expectedBuf = Buffer.from(expected);
|
||||
const valid =
|
||||
providedBuf.length === expectedBuf.length && crypto.timingSafeEqual(providedBuf, expectedBuf);
|
||||
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'invalid_webhook_secret' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = calendarWebhookAuth;
|
||||
25
src/lib/calendarWorkflowState.js
Normal file
25
src/lib/calendarWorkflowState.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// In-memory only — resets on process restart, which is fine: it just tracks
|
||||
// whether the last "refresh availability" run is idle/running/done/failed,
|
||||
// not data that needs to survive a restart. Shared between the session-gated
|
||||
// routes (src/routes/calendar.js) and the n8n-facing webhook routes
|
||||
// (src/routes/calendarWebhooks.js) so both can read/update the same state
|
||||
// without a require cycle between those two route files.
|
||||
let state = { status: 'idle', triggeredAt: null, message: null, node: null };
|
||||
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function setRunning() {
|
||||
state = { status: 'running', triggeredAt: new Date().toISOString(), message: null, node: null };
|
||||
}
|
||||
|
||||
function setSuccess() {
|
||||
state = { ...state, status: 'success', message: null, node: null };
|
||||
}
|
||||
|
||||
function setError(message, node) {
|
||||
state = { ...state, status: 'error', message: message || 'Unknown error', node: node || null };
|
||||
}
|
||||
|
||||
module.exports = { getState, setRunning, setSuccess, setError };
|
||||
125
src/repositories/calendarRepo.js
Normal file
125
src/repositories/calendarRepo.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
const pool = require('../db/pool');
|
||||
const { normalizeISO, slotDateParis, dayOfWeekParis } = require('../lib/calendarDates');
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#4285f4', '#ea4335', '#fbbc05', '#34a853',
|
||||
'#a142f4', '#24c1e0', '#ff6d00', '#795548',
|
||||
];
|
||||
|
||||
async function getPeople() {
|
||||
const { rows } = await pool.query('SELECT id, name, color FROM calendar_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;
|
||||
}
|
||||
|
||||
async function ingestSlots(slots) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const slot of slots) {
|
||||
const lower = normalizeISO(slot.lower);
|
||||
const upper = normalizeISO(slot.upper);
|
||||
const slotDate = slotDateParis(slot.lower);
|
||||
const dayOfWeek = dayOfWeekParis(slot.lower);
|
||||
|
||||
const { rows: slotRows } = await client.query(
|
||||
`INSERT INTO calendar_slots (lower, upper, slot_date, day_of_week)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (lower, upper) DO UPDATE SET
|
||||
slot_date = excluded.slot_date,
|
||||
day_of_week = excluded.day_of_week
|
||||
RETURNING id`,
|
||||
[lower, upper, slotDate, dayOfWeek]
|
||||
);
|
||||
const slotId = slotRows[0].id;
|
||||
|
||||
// people may arrive as a JSON string from n8n's Set-node serialization.
|
||||
const people = typeof slot.people === 'string' ? JSON.parse(slot.people) : slot.people || [];
|
||||
|
||||
for (const person of people) {
|
||||
if (!person || !person.name) continue;
|
||||
const personId = await upsertPerson(client, person.name);
|
||||
await client.query(
|
||||
`INSERT INTO calendar_availability (slot_id, person_id, is_available, checked_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (slot_id, person_id) DO UPDATE SET
|
||||
is_available = excluded.is_available,
|
||||
checked_at = excluded.checked_at`,
|
||||
[slotId, personId, !!person.available]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function getSlots({ minPeople = 1, personIds = null, weeks = 3 } = {}) {
|
||||
const cappedWeeks = Math.min(weeks || 3, 3);
|
||||
const now = new Date();
|
||||
const end = new Date(now);
|
||||
end.setDate(end.getDate() + cappedWeeks * 7);
|
||||
|
||||
const nowStr = now.toISOString().substring(0, 10);
|
||||
const endStr = end.toISOString().substring(0, 10);
|
||||
const hasPersonFilter = Array.isArray(personIds) && personIds.length > 0;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
ts.id, ts.lower, ts.upper, ts.slot_date, ts.day_of_week,
|
||||
filtered.available_count, filtered.total_in_filter,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object('id', p.id, 'name', p.name, 'color', p.color,
|
||||
'is_available', sa2.is_available)
|
||||
ORDER BY p.id
|
||||
)
|
||||
FROM calendar_availability sa2
|
||||
JOIN calendar_people p ON p.id = sa2.person_id
|
||||
WHERE sa2.slot_id = ts.id
|
||||
) AS people
|
||||
FROM calendar_slots ts
|
||||
JOIN (
|
||||
SELECT slot_id,
|
||||
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[]))
|
||||
GROUP BY slot_id
|
||||
) filtered ON filtered.slot_id = ts.id
|
||||
WHERE ts.slot_date >= $1 AND ts.slot_date <= $2
|
||||
AND filtered.available_count >= $4
|
||||
ORDER BY ts.lower`,
|
||||
[nowStr, endStr, hasPersonFilter ? personIds : null, minPeople]
|
||||
);
|
||||
|
||||
return rows.map((row) => ({ ...row, people: row.people || [] }));
|
||||
}
|
||||
|
||||
async function getLastChecked() {
|
||||
const { rows } = await pool.query('SELECT MAX(checked_at) AS ts FROM calendar_availability');
|
||||
return rows[0].ts;
|
||||
}
|
||||
|
||||
module.exports = { getPeople, ingestSlots, getSlots, getLastChecked };
|
||||
|
|
@ -2,7 +2,7 @@ const pool = require('../db/pool');
|
|||
|
||||
async function findAll() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count
|
||||
|
|
@ -17,7 +17,7 @@ async function findAll() {
|
|||
|
||||
async function findById(id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name
|
||||
FROM suggestions sg
|
||||
JOIN users u ON u.id = sg.suggested_by
|
||||
|
|
@ -39,12 +39,12 @@ async function findVotes(suggestionId) {
|
|||
return rows;
|
||||
}
|
||||
|
||||
async function create({ title, artist, youtubeUrl, description, suggestedBy }) {
|
||||
async function create({ title, artist, youtubeUrl, spotifyUrl, description, suggestedBy }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO suggestions (title, artist, youtube_url, description, suggested_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||
[title, artist || null, youtubeUrl, description || null, suggestedBy]
|
||||
`INSERT INTO suggestions (title, artist, youtube_url, spotify_url, description, suggested_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||
[title, artist || null, youtubeUrl, spotifyUrl || null, description || null, suggestedBy]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ async function create({ title, artist, youtubeUrl, description, suggestedBy }) {
|
|||
async function updateStatus(id, status) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE suggestions SET status = $2, updated_at = now() WHERE id = $1
|
||||
RETURNING id, title, artist, youtube_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||
RETURNING id, title, artist, youtube_url, spotify_url, description, status, promoted_song_id, created_at, suggested_by`,
|
||||
[id, status]
|
||||
);
|
||||
return rows[0] || null;
|
||||
|
|
@ -94,10 +94,17 @@ async function promoteToSong(suggestionId, addedBy) {
|
|||
return null;
|
||||
}
|
||||
const { rows: songRows } = await client.query(
|
||||
`INSERT INTO songs (title, artist, notes, youtube_url, added_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO songs (title, artist, notes, youtube_url, spotify_url, added_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, title, artist, notes, youtube_url, spotify_url, added_by, created_at, updated_at`,
|
||||
[suggestion.title, suggestion.artist || suggestion.title, suggestion.description, suggestion.youtube_url, addedBy]
|
||||
[
|
||||
suggestion.title,
|
||||
suggestion.artist || suggestion.title,
|
||||
suggestion.description,
|
||||
suggestion.youtube_url,
|
||||
suggestion.spotify_url,
|
||||
addedBy,
|
||||
]
|
||||
);
|
||||
const song = songRows[0];
|
||||
await client.query(
|
||||
|
|
|
|||
96
src/routes/calendar.js
Normal file
96
src/routes/calendar.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const express = require('express');
|
||||
const calendarRepo = require('../repositories/calendarRepo');
|
||||
const workflowState = require('../lib/calendarWorkflowState');
|
||||
const config = require('../config');
|
||||
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) => {
|
||||
const minPeople = req.query.min_people !== undefined ? parseInt(req.query.min_people, 10) : 1;
|
||||
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());
|
||||
});
|
||||
|
||||
function webhookHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (config.n8nWebhookUser && config.n8nWebhookPass) {
|
||||
const token = Buffer.from(`${config.n8nWebhookUser}:${config.n8nWebhookPass}`).toString('base64');
|
||||
headers.Authorization = `Basic ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
router.post('/refresh', (req, res) => {
|
||||
if (!config.n8nWebhookUrl) {
|
||||
return res.status(400).json({ error: 'n8n_not_configured' });
|
||||
}
|
||||
if (workflowState.getState().status === 'running') {
|
||||
return res.status(409).json({ ok: false, error: 'A refresh is already in progress' });
|
||||
}
|
||||
|
||||
workflowState.setRunning();
|
||||
res.json({ ok: true });
|
||||
|
||||
// Fire-and-forget: the browser polls GET /workflow-status for the result.
|
||||
(async () => {
|
||||
const controller = new AbortController();
|
||||
const watchdog = setTimeout(() => controller.abort(), 5 * 60 * 1000);
|
||||
try {
|
||||
const response = await fetch(config.n8nWebhookUrl, {
|
||||
method: 'GET',
|
||||
headers: webhookHeaders(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(watchdog);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
workflowState.setError(`n8n returned ${response.status}: ${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.slots)) {
|
||||
workflowState.setError('Unexpected response format from n8n');
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarRepo.ingestSlots(data.slots);
|
||||
workflowState.setSuccess();
|
||||
} catch (err) {
|
||||
clearTimeout(watchdog);
|
||||
const message = err.name === 'AbortError' ? 'n8n did not respond within 5 minutes' : err.message;
|
||||
console.error('[calendar] n8n refresh failed:', message);
|
||||
workflowState.setError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
42
src/routes/calendarWebhooks.js
Normal file
42
src/routes/calendarWebhooks.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const express = require('express');
|
||||
const calendarRepo = require('../repositories/calendarRepo');
|
||||
const workflowState = require('../lib/calendarWorkflowState');
|
||||
const calendarWebhookAuth = require('../lib/calendarWebhookAuth');
|
||||
const asyncHandler = require('../lib/asyncHandler');
|
||||
|
||||
// Mounted directly in app.js, before the session-gated /api router — these
|
||||
// two routes are called by n8n itself (server-to-server), so they cannot
|
||||
// rely on a browser session cookie. The secret check is applied per-route
|
||||
// here rather than as middleware on the whole /api/calendar mount, so that
|
||||
// unmatched paths (e.g. GET /api/calendar/people) fall through untouched to
|
||||
// the normal session-gated router mounted afterwards.
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/ingest',
|
||||
calendarWebhookAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const slots = Array.isArray(req.body) ? req.body : req.body?.slots;
|
||||
if (!Array.isArray(slots)) {
|
||||
return res.status(400).json({ error: 'expected_array_of_slots' });
|
||||
}
|
||||
try {
|
||||
await calendarRepo.ingestSlots(slots);
|
||||
workflowState.setSuccess();
|
||||
res.json({ ok: true, count: slots.length });
|
||||
} catch (err) {
|
||||
workflowState.setError(err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/workflow-error', calendarWebhookAuth, (req, res) => {
|
||||
const message = req.body?.message || req.body?.error || 'Workflow failed';
|
||||
const node = req.body?.node || null;
|
||||
console.error('[calendar] n8n workflow error:', message, node ? `(node: ${node})` : '');
|
||||
workflowState.setError(message, node);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -5,6 +5,7 @@ const songsRoutes = require('./songs');
|
|||
const suggestionsRoutes = require('./suggestions');
|
||||
const setlistsRoutes = require('./setlists');
|
||||
const musicSearchRoutes = require('./musicSearch');
|
||||
const calendarRoutes = require('./calendar');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -14,5 +15,6 @@ router.use('/songs', songsRoutes);
|
|||
router.use('/suggestions', suggestionsRoutes);
|
||||
router.use('/setlists', setlistsRoutes);
|
||||
router.use('/music-search', musicSearchRoutes);
|
||||
router.use('/calendar', calendarRoutes);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
|||
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
||||
const { requireAdmin } = require('../auth/middleware');
|
||||
const { isValidYoutubeUrl } = require('../lib/youtube');
|
||||
const { isValidSpotifyUrl } = require('../lib/spotify');
|
||||
const asyncHandler = require('../lib/asyncHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -26,17 +27,21 @@ router.get(
|
|||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { title, artist, youtubeUrl, description } = req.body || {};
|
||||
const { title, artist, youtubeUrl, spotifyUrl, description } = req.body || {};
|
||||
if (!title || !title.trim() || !youtubeUrl || !youtubeUrl.trim()) {
|
||||
return res.status(400).json({ error: 'title_and_youtube_url_required' });
|
||||
}
|
||||
if (!isValidYoutubeUrl(youtubeUrl.trim())) {
|
||||
return res.status(400).json({ error: 'invalid_youtube_url' });
|
||||
}
|
||||
if (spotifyUrl && !isValidSpotifyUrl(spotifyUrl.trim())) {
|
||||
return res.status(400).json({ error: 'invalid_spotify_url' });
|
||||
}
|
||||
const suggestion = await suggestionsRepo.create({
|
||||
title: title.trim(),
|
||||
artist: artist ? artist.trim() : null,
|
||||
youtubeUrl: youtubeUrl.trim(),
|
||||
spotifyUrl: spotifyUrl ? spotifyUrl.trim() : null,
|
||||
description: description ? description.trim() : null,
|
||||
suggestedBy: req.user.id,
|
||||
});
|
||||
|
|
@ -91,7 +96,6 @@ router.delete(
|
|||
|
||||
router.post(
|
||||
'/:id/promote',
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const song = await suggestionsRepo.promoteToSong(req.params.id, req.user.id);
|
||||
if (!song) return res.status(404).json({ error: 'not_found' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue