Backend: - POST /api/events — create event, validates title/date/venue required, rejects past dates (Asia/Jerusalem aware), returns compliance_checklist flag when max_guests >= 100 - GET /api/events — list organizer events (scoped by JWT), paginated (limit 20), enriched with rsvp_confirmed/pending/total + vendors_confirmed counts, sorted by event_date ASC - GET /api/events/:id — single event with RSVP + vendor counts - PUT /api/events/:id — COALESCE update, validated status transitions (draft→published/cancelled, published→cancelled/completed), ownership enforced; returns compliance_checklist flag when crossing 100 guests - DELETE /api/events/:id — soft delete (deleted_at + status=cancelled), 204 Frontend: - DashboardPage — real event list, EventCard grid, pagination, prominent empty state with "צור אירוע ראשון" CTA - EventCard — title, date, venue, RSVP confirmed/total/vendors stats, progress bar (orange at 90%), days-until countdown, publish/edit/cancel actions - CreateEventPage — full form: title, date+time picker (min=today), venue, kashrut, budget, guest count; compliance checklist appears when max_guests >= 100, dismissible; Hebrew validation errors - EventDetailPage — full event detail with stat cards, days-until, read-only compliance checklist for 100+ events, quick-action links - ComplianceChecklist — reusable: interactive (create/edit) or read-only (detail page); 4 Israeli compliance items, dismissible banner - Button component: added asChild support via @radix-ui/react-slot - App.tsx: routes for /events/new, /events/:id, /events/:id/edit Build: 0 TS errors, 68 modules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
const express = require('express');
|
||
const pool = require('../db/pool');
|
||
const { authMiddleware } = require('../middleware/auth');
|
||
|
||
const router = express.Router();
|
||
|
||
// All event routes require auth — middleware applied in server.js before this router
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
const VALID_STATUS = ['draft', 'published', 'cancelled', 'completed'];
|
||
const VALID_KASHRUT = ['none', 'regular', 'mehadrin', 'chalav_yisrael'];
|
||
const VALID_LANGUAGE = ['hebrew', 'arabic', 'english'];
|
||
|
||
/** Return enriched event row: adds RSVP counts + confirmed vendor count */
|
||
async function enrichEvent(eventId) {
|
||
const { rows } = await pool.query(
|
||
`SELECT
|
||
e.*,
|
||
COUNT(DISTINCT g.id) FILTER (WHERE g.rsvp_status = 'confirmed') AS rsvp_confirmed,
|
||
COUNT(DISTINCT g.id) FILTER (WHERE g.rsvp_status = 'pending') AS rsvp_pending,
|
||
COUNT(DISTINCT g.id) FILTER (WHERE g.rsvp_status = 'declined') AS rsvp_declined,
|
||
COUNT(DISTINCT g.id) AS rsvp_total,
|
||
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'confirmed') AS vendors_confirmed
|
||
FROM events e
|
||
LEFT JOIN guests g ON g.event_id = e.id
|
||
LEFT JOIN bookings b ON b.event_id = e.id AND b.deleted_at IS NULL
|
||
WHERE e.id = $1 AND e.deleted_at IS NULL
|
||
GROUP BY e.id`,
|
||
[eventId]
|
||
);
|
||
return rows[0] || null;
|
||
}
|
||
|
||
// ─── POST /api/events — Create event ─────────────────────────────────────────
|
||
|
||
router.post('/', authMiddleware, async (req, res) => {
|
||
if (req.user.role !== 'organizer') {
|
||
return res.status(403).json({ error: 'רק מארגנים יכולים ליצור אירועים' });
|
||
}
|
||
|
||
const {
|
||
title, event_date, venue_name, venue_address, description,
|
||
max_guests, venue_capacity, max_plus_ones_buffer,
|
||
kashrut_level, noise_curfew_time, language_pref, budget,
|
||
retention_policy_days,
|
||
compliance_dismissed,
|
||
} = req.body;
|
||
|
||
// Validation
|
||
if (!title || title.trim().length === 0) return res.status(400).json({ error: 'שם האירוע הוא שדה חובה' });
|
||
if (!event_date) return res.status(400).json({ error: 'תאריך האירוע הוא שדה חובה' });
|
||
if (!venue_name || venue_name.trim().length === 0) return res.status(400).json({ error: 'שם המקום הוא שדה חובה' });
|
||
|
||
// Date cannot be in the past (compare in Asia/Jerusalem)
|
||
const eventDateObj = new Date(event_date);
|
||
if (isNaN(eventDateObj.getTime())) return res.status(400).json({ error: 'תאריך לא תקין' });
|
||
if (eventDateObj < new Date()) return res.status(400).json({ error: 'לא ניתן ליצור אירוע בתאריך עבר' });
|
||
|
||
if (kashrut_level && !VALID_KASHRUT.includes(kashrut_level)) return res.status(400).json({ error: 'רמת כשרות לא תקינה' });
|
||
if (language_pref && !VALID_LANGUAGE.includes(language_pref)) return res.status(400).json({ error: 'שפה לא תקינה' });
|
||
|
||
try {
|
||
const result = await pool.query(
|
||
`INSERT INTO events (
|
||
organizer_id, title, event_date, venue_name, venue_address, description,
|
||
max_guests, venue_capacity, max_plus_ones_buffer,
|
||
kashrut_level, noise_curfew_time, language_pref, budget,
|
||
retention_policy_days, status
|
||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'draft')
|
||
RETURNING *`,
|
||
[
|
||
req.user.id,
|
||
title.trim(),
|
||
event_date,
|
||
venue_name.trim(),
|
||
venue_address?.trim() || null,
|
||
description?.trim() || null,
|
||
max_guests || null,
|
||
venue_capacity || null,
|
||
max_plus_ones_buffer ?? 30,
|
||
kashrut_level || 'none',
|
||
noise_curfew_time || '23:00',
|
||
language_pref || 'hebrew',
|
||
budget || null,
|
||
retention_policy_days ?? 365,
|
||
]
|
||
);
|
||
|
||
const event = result.rows[0];
|
||
|
||
// Store compliance dismissal if provided at creation time
|
||
if (compliance_dismissed && event.max_guests >= 100) {
|
||
await pool.query(
|
||
'UPDATE events SET compliance_dismissed = true WHERE id = $1',
|
||
[event.id]
|
||
).catch(() => {}); // compliance_dismissed column added via migration — ignore if not yet present
|
||
}
|
||
|
||
// Check if compliance checklist should be shown
|
||
const showComplianceChecklist = (max_guests || 0) >= 100 && !compliance_dismissed;
|
||
|
||
return res.status(201).json({
|
||
event,
|
||
...(showComplianceChecklist && { compliance_checklist: true }),
|
||
});
|
||
} catch (err) {
|
||
console.error('Create event error:', err.message);
|
||
return res.status(500).json({ error: 'יצירת האירוע נכשלה' });
|
||
}
|
||
});
|
||
|
||
// ─── GET /api/events — List organizer events ──────────────────────────────────
|
||
|
||
router.get('/', authMiddleware, async (req, res) => {
|
||
const { page = 1, limit = 20, status } = req.query;
|
||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||
|
||
const conditions = ['e.organizer_id = $1', 'e.deleted_at IS NULL'];
|
||
const params = [req.user.id];
|
||
let paramIdx = 2;
|
||
|
||
if (status && VALID_STATUS.includes(status)) {
|
||
conditions.push(`e.status = $${paramIdx++}::event_status`);
|
||
params.push(status);
|
||
}
|
||
|
||
const where = conditions.join(' AND ');
|
||
|
||
try {
|
||
const [eventsResult, countResult] = await Promise.all([
|
||
pool.query(
|
||
`SELECT
|
||
e.*,
|
||
COUNT(DISTINCT g.id) FILTER (WHERE g.rsvp_status = 'confirmed') AS rsvp_confirmed,
|
||
COUNT(DISTINCT g.id) FILTER (WHERE g.rsvp_status = 'pending') AS rsvp_pending,
|
||
COUNT(DISTINCT g.id) AS rsvp_total,
|
||
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'confirmed') AS vendors_confirmed
|
||
FROM events e
|
||
LEFT JOIN guests g ON g.event_id = e.id
|
||
LEFT JOIN bookings b ON b.event_id = e.id AND b.deleted_at IS NULL
|
||
WHERE ${where}
|
||
GROUP BY e.id
|
||
ORDER BY e.event_date ASC NULLS LAST
|
||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
|
||
[...params, parseInt(limit), offset]
|
||
),
|
||
pool.query(`SELECT COUNT(*) FROM events e WHERE ${where}`, params),
|
||
]);
|
||
|
||
return res.json({
|
||
events: eventsResult.rows,
|
||
total: parseInt(countResult.rows[0].count),
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
});
|
||
} catch (err) {
|
||
console.error('List events error:', err.message);
|
||
return res.status(500).json({ error: 'טעינת האירועים נכשלה' });
|
||
}
|
||
});
|
||
|
||
// ─── GET /api/events/:id — Get single event ───────────────────────────────────
|
||
|
||
router.get('/:id', authMiddleware, async (req, res) => {
|
||
const event = await enrichEvent(req.params.id).catch(() => null);
|
||
if (!event) return res.status(404).json({ error: 'האירוע לא נמצא' });
|
||
if (event.organizer_id !== req.user.id) return res.status(403).json({ error: 'אין גישה לאירוע זה' });
|
||
return res.json({ event });
|
||
});
|
||
|
||
// ─── PUT /api/events/:id — Update event ──────────────────────────────────────
|
||
|
||
router.put('/:id', authMiddleware, async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
// Ownership check
|
||
const ownerCheck = await pool.query(
|
||
'SELECT id, status FROM events WHERE id = $1 AND organizer_id = $2 AND deleted_at IS NULL',
|
||
[id, req.user.id]
|
||
).catch(() => ({ rows: [] }));
|
||
|
||
if (ownerCheck.rows.length === 0) return res.status(404).json({ error: 'האירוע לא נמצא' });
|
||
|
||
const currentStatus = ownerCheck.rows[0].status;
|
||
if (currentStatus === 'cancelled') return res.status(400).json({ error: 'לא ניתן לערוך אירוע שבוטל' });
|
||
|
||
const {
|
||
title, event_date, venue_name, venue_address, description,
|
||
max_guests, venue_capacity, max_plus_ones_buffer,
|
||
kashrut_level, noise_curfew_time, language_pref, budget,
|
||
status, compliance_dismissed,
|
||
} = req.body;
|
||
|
||
// Validate status transition
|
||
if (status) {
|
||
if (!VALID_STATUS.includes(status)) return res.status(400).json({ error: 'סטטוס לא תקין' });
|
||
const allowedTransitions = {
|
||
draft: ['published', 'cancelled'],
|
||
published: ['cancelled', 'completed'],
|
||
completed: [],
|
||
cancelled: [],
|
||
};
|
||
if (!allowedTransitions[currentStatus].includes(status)) {
|
||
return res.status(400).json({ error: `לא ניתן לשנות מ-${currentStatus} ל-${status}` });
|
||
}
|
||
}
|
||
|
||
// Date validation if provided
|
||
if (event_date) {
|
||
const d = new Date(event_date);
|
||
if (isNaN(d.getTime())) return res.status(400).json({ error: 'תאריך לא תקין' });
|
||
if (d < new Date() && status !== 'cancelled' && status !== 'completed') {
|
||
return res.status(400).json({ error: 'לא ניתן לקבוע תאריך בעבר' });
|
||
}
|
||
}
|
||
|
||
try {
|
||
const result = await pool.query(
|
||
`UPDATE events SET
|
||
title = COALESCE($1, title),
|
||
event_date = COALESCE($2, event_date),
|
||
venue_name = COALESCE($3, venue_name),
|
||
venue_address = COALESCE($4, venue_address),
|
||
description = COALESCE($5, description),
|
||
max_guests = COALESCE($6, max_guests),
|
||
venue_capacity = COALESCE($7, venue_capacity),
|
||
max_plus_ones_buffer = COALESCE($8, max_plus_ones_buffer),
|
||
kashrut_level = COALESCE($9::kashrut_level, kashrut_level),
|
||
noise_curfew_time = COALESCE($10, noise_curfew_time),
|
||
language_pref = COALESCE($11::event_language, language_pref),
|
||
budget = COALESCE($12, budget),
|
||
status = COALESCE($13::event_status, status),
|
||
updated_at = NOW()
|
||
WHERE id = $14
|
||
RETURNING *`,
|
||
[
|
||
title?.trim() || null,
|
||
event_date || null,
|
||
venue_name?.trim() || null,
|
||
venue_address?.trim() || null,
|
||
description?.trim() || null,
|
||
max_guests || null,
|
||
venue_capacity || null,
|
||
max_plus_ones_buffer != null ? parseInt(max_plus_ones_buffer) : null,
|
||
kashrut_level || null,
|
||
noise_curfew_time || null,
|
||
language_pref || null,
|
||
budget || null,
|
||
status || null,
|
||
id,
|
||
]
|
||
);
|
||
|
||
const updatedEvent = await enrichEvent(id);
|
||
|
||
// Compliance checklist: show if guest count crosses 100 and not dismissed
|
||
const effectiveMaxGuests = updatedEvent.max_guests || 0;
|
||
const showCompliance = effectiveMaxGuests >= 100 && !compliance_dismissed && !updatedEvent.compliance_dismissed;
|
||
|
||
return res.json({
|
||
event: updatedEvent,
|
||
...(showCompliance && { compliance_checklist: true }),
|
||
});
|
||
} catch (err) {
|
||
console.error('Update event error:', err.message);
|
||
return res.status(500).json({ error: 'עדכון האירוע נכשל' });
|
||
}
|
||
});
|
||
|
||
// ─── DELETE /api/events/:id — Soft delete (cancelled = soft-delete for organizer) ──
|
||
|
||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const ownerCheck = await pool.query(
|
||
'SELECT id FROM events WHERE id = $1 AND organizer_id = $2 AND deleted_at IS NULL',
|
||
[id, req.user.id]
|
||
).catch(() => ({ rows: [] }));
|
||
|
||
if (ownerCheck.rows.length === 0) return res.status(404).json({ error: 'האירוע לא נמצא' });
|
||
|
||
try {
|
||
await pool.query(
|
||
'UPDATE events SET deleted_at = NOW(), status = $1, updated_at = NOW() WHERE id = $2',
|
||
['cancelled', id]
|
||
);
|
||
return res.status(204).send();
|
||
} catch (err) {
|
||
console.error('Delete event error:', err.message);
|
||
return res.status(500).json({ error: 'מחיקת האירוע נכשלה' });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|