From 5730a9fe3cc11f0c207cd4f85b14a2a29f326f84 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Wed, 15 Jul 2026 17:25:32 +0200 Subject: [PATCH] update: accept/reject rehersal and get response information --- public/css/style.css | 21 +++++++++ public/js/calendar.js | 54 +++++++++++++++++++++++ src/db/migrations/015_rehearsal_votes.sql | 9 ++++ src/repositories/rehearsalsRepo.js | 32 +++++++++++++- src/routes/rehearsals.js | 28 ++++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/db/migrations/015_rehearsal_votes.sql diff --git a/public/css/style.css b/public/css/style.css index 9c76e0b..9784958 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1461,6 +1461,27 @@ a.back-link:hover { color: var(--accent); } flex-wrap: wrap; } +.rehearsal-accepted-by { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem 0.5rem; + margin-top: 0.35rem; + font-size: 0.8rem; +} + +.rehearsal-accepted-by .avatar-sm { width: 1.3rem; height: 1.3rem; font-size: 0.65rem; } + +.vote-badge { + font-size: 0.85rem; + border-radius: var(--radius-sm); + padding: 0.35rem 0.6rem; + background: var(--surface-alt); +} + +.vote-badge.accept { border-left: 3px solid var(--success); } +.vote-badge.reject { border-left: 3px solid var(--danger); } + .cell-badges { position: absolute; top: 0.3rem; diff --git a/public/js/calendar.js b/public/js/calendar.js index a90cb40..e69c3f4 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -394,6 +394,31 @@ function showError(message) { // ---------- Répétitions proposées ---------- +function rehearsalVoteActionsHtml(r) { + const myVote = me ? r.votes.find((v) => v.userId === me.id) : null; + if (!myVote) { + return ` + + + `; + } + const label = myVote.vote === 'accept' ? '✔ Vous avez accepté' : '✘ Vous avez refusé'; + return ` + ${label} + + `; +} + +function rehearsalAcceptedByHtml(r) { + const accepted = r.votes.filter((v) => v.vote === 'accept'); + if (!accepted.length) return ''; + return ` +
+ ${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}${escapeHtml(v.name)}`).join('')} +
+ `; +} + function rehearsalRowTemplate(r) { const title = 'Répétition Octane'; const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location }; @@ -403,8 +428,10 @@ function rehearsalRowTemplate(r) {
${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}
${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}
+ ${rehearsalAcceptedByHtml(r)}
+ ${rehearsalVoteActionsHtml(r)} + Google + Outlook + Apple / autre @@ -426,6 +453,15 @@ function renderRehearsals() { container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => { btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10))); }); + container.querySelectorAll('.accept-rehearsal-btn').forEach((btn) => { + btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'accept')); + }); + container.querySelectorAll('.reject-rehearsal-btn').forEach((btn) => { + btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'reject')); + }); + container.querySelectorAll('.undo-rehearsal-vote-btn').forEach((btn) => { + btn.addEventListener('click', () => onUndoRehearsalVote(parseInt(btn.dataset.rehearsalId, 10))); + }); } async function onProposeRehearsal() { @@ -450,6 +486,24 @@ async function onRemoveRehearsal(id) { } } +async function onVoteRehearsal(id, vote) { + try { + await api.post(`/api/rehearsals/${id}/vote`, { vote }); + await loadRehearsals(); + } catch (err) { + showError(err.message); + } +} + +async function onUndoRehearsalVote(id) { + try { + await api.del(`/api/rehearsals/${id}/vote`); + await loadRehearsals(); + } catch (err) { + showError(err.message); + } +} + // ---------- Mes calendriers (self-service ICS feeds) ---------- let myFeeds = []; diff --git a/src/db/migrations/015_rehearsal_votes.sql b/src/db/migrations/015_rehearsal_votes.sql new file mode 100644 index 0000000..dfa5020 --- /dev/null +++ b/src/db/migrations/015_rehearsal_votes.sql @@ -0,0 +1,9 @@ +CREATE TABLE rehearsal_votes ( + id SERIAL PRIMARY KEY, + rehearsal_id INTEGER NOT NULL REFERENCES rehearsals(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vote TEXT NOT NULL CHECK (vote IN ('accept', 'reject')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (rehearsal_id, user_id) +); diff --git a/src/repositories/rehearsalsRepo.js b/src/repositories/rehearsalsRepo.js index abf52b0..6a7c7b2 100644 --- a/src/repositories/rehearsalsRepo.js +++ b/src/repositories/rehearsalsRepo.js @@ -2,10 +2,19 @@ const pool = require('../db/pool'); async function findUpcoming() { const { rows } = await pool.query(` - SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at + SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at, + COALESCE( + json_agg( + json_build_object('userId', v.user_id, 'name', vu.name, 'avatarUrl', vu.avatar_url, 'vote', v.vote) + ) FILTER (WHERE v.id IS NOT NULL), + '[]' + ) AS votes FROM rehearsals r JOIN users u ON u.id = r.proposed_by + LEFT JOIN rehearsal_votes v ON v.rehearsal_id = r.id + LEFT JOIN users vu ON vu.id = v.user_id WHERE r.ends_at >= now() + GROUP BY r.id, u.name ORDER BY r.starts_at `); return rows; @@ -30,4 +39,23 @@ async function remove(id) { await pool.query('DELETE FROM rehearsals WHERE id = $1', [id]); } -module.exports = { findUpcoming, findById, create, remove }; +async function upsertVote(rehearsalId, userId, vote) { + const { rows } = await pool.query( + `INSERT INTO rehearsal_votes (rehearsal_id, user_id, vote) + VALUES ($1, $2, $3) + ON CONFLICT (rehearsal_id, user_id) + DO UPDATE SET vote = $3, updated_at = now() + RETURNING id, rehearsal_id, user_id, vote, created_at, updated_at`, + [rehearsalId, userId, vote] + ); + return rows[0]; +} + +async function removeVote(rehearsalId, userId) { + await pool.query('DELETE FROM rehearsal_votes WHERE rehearsal_id = $1 AND user_id = $2', [ + rehearsalId, + userId, + ]); +} + +module.exports = { findUpcoming, findById, create, remove, upsertVote, removeVote }; diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js index 212260e..ea510b0 100644 --- a/src/routes/rehearsals.js +++ b/src/routes/rehearsals.js @@ -16,6 +16,7 @@ router.get( location: r.location, proposedBy: r.proposed_by, proposedByName: r.proposed_by_name, + votes: r.votes, })) ); }) @@ -68,4 +69,31 @@ router.delete( }) ); +router.post( + '/:id/vote', + asyncHandler(async (req, res) => { + const { vote } = req.body || {}; + if (!['accept', 'reject'].includes(vote)) { + return res.status(400).json({ error: 'invalid_vote' }); + } + const rehearsal = await rehearsalsRepo.findById(req.params.id); + if (!rehearsal) return res.status(404).json({ error: 'not_found' }); + const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote); + res.json({ + id: saved.id, + rehearsalId: saved.rehearsal_id, + userId: saved.user_id, + vote: saved.vote, + }); + }) +); + +router.delete( + '/:id/vote', + asyncHandler(async (req, res) => { + await rehearsalsRepo.removeVote(req.params.id, req.user.id); + res.status(204).end(); + }) +); + module.exports = router;