@@ -551,54 +553,35 @@ export default function AdminPage() {
)}
-
-
-
-
setWeatherKey(e.target.value)}
- placeholder={t('settings.keyPlaceholder')}
- className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
- />
-
+ {/* Open-Meteo Weather Info */}
+
+
+
+
+
+
+
{t('admin.weather.title')}
+
+
{t('admin.weather.badge')}
+
+
+
{t('admin.weather.description')}
+
{t('admin.weather.locationHint')}
+
+
+
{t('admin.weather.forecast')}
+
{t('admin.weather.forecastDesc')}
+
+
+
{t('admin.weather.climate')}
+
{t('admin.weather.climateDesc')}
+
+
+
{t('admin.weather.requests')}
+
{t('admin.weather.requestsDesc')}
+
-
-
{t('admin.weatherKeyHint')}
- {validation.weather === true && (
-
-
- {t('admin.keyValid')}
-
- )}
- {validation.weather === false && (
-
-
- {t('admin.keyInvalid')}
-
- )}
}
+
+ {activeTab === 'github' &&
}
diff --git a/docker-compose.yml b/docker-compose.yml
index b311145..820e85f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
app:
- image: mauriceboe/nomad:latest
+ image: mauriceboe/nomad:2.5.5
container_name: nomad
ports:
- "3000:3000"
diff --git a/server/package-lock.json b/server/package-lock.json
index 47771d4..ca32792 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nomad-server",
- "version": "2.5.2",
+ "version": "2.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
- "version": "2.5.2",
+ "version": "2.5.5",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
diff --git a/server/package.json b/server/package.json
index 0176f9a..5f2a7fc 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "nomad-server",
- "version": "2.5.5",
+ "version": "2.5.6",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
diff --git a/server/src/index.js b/server/src/index.js
index 410dd56..26685fc 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -107,23 +107,6 @@ app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
-// Exchange rates (cached 1h, authenticated)
-const { authenticate: rateAuth } = require('./middleware/auth');
-let _rateCache = { data: null, ts: 0 };
-app.get('/api/exchange-rates', rateAuth, async (req, res) => {
- const now = Date.now();
- if (_rateCache.data && now - _rateCache.ts < 3600000) return res.json(_rateCache.data);
- try {
- const r = await fetch('https://api.frankfurter.app/latest?from=EUR');
- if (!r.ok) return res.status(502).json({ error: 'Failed to fetch rates' });
- const data = await r.json();
- _rateCache = { data, ts: now };
- res.json(data);
- } catch {
- res.status(502).json({ error: 'Failed to fetch rates' });
- }
-});
-
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
diff --git a/server/src/routes/vacay.js b/server/src/routes/vacay.js
index 9e5449c..af41a78 100644
--- a/server/src/routes/vacay.js
+++ b/server/src/routes/vacay.js
@@ -9,8 +9,8 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
const router = express.Router();
router.use(authenticate);
-// Broadcast vacay updates to all users in the same plan
-function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
+// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
+function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
try {
const { broadcastToUser } = require('../websocket');
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
@@ -18,7 +18,7 @@ function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
const userIds = [plan.owner_id];
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
members.forEach(m => userIds.push(m.user_id));
- userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event }));
+ userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
} catch { /* */ }
}
@@ -191,7 +191,7 @@ router.put('/plan', async (req, res) => {
}
}
- notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+ notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
res.json({
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`).run(userId, planId, color || '#6366f1');
- notifyPlanUsers(planId, req.user.id, 'vacay:update');
+ notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
res.json({ success: true });
});
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
}
// Notify all plan users (not just owner)
- notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
+ notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
res.json({ success: true });
});
@@ -310,7 +310,7 @@ router.post('/invite/decline', (req, res) => {
const { plan_id } = req.body;
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
- notifyPlanUsers(plan_id, req.user.id, 'vacay:declined');
+ notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
res.json({ success: true });
});
@@ -417,7 +417,7 @@ router.post('/years', (req, res) => {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
}
} catch { /* exists */ }
- notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+ notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
res.json({ years: years.map(y => y.year) });
});
@@ -428,7 +428,7 @@ router.delete('/years/:year', (req, res) => {
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
- notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+ notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
res.json({ years: years.map(y => y.year) });
});
@@ -453,28 +453,10 @@ router.post('/entries/toggle', (req, res) => {
const { date, target_user_id } = req.body;
if (!date) return res.status(400).json({ error: 'date required' });
const planId = getActivePlanId(req.user.id);
- const planUsers = getPlanUsers(planId);
-
- // Toggle for all users in plan
- if (target_user_id === 'all') {
- const actions = [];
- for (const u of planUsers) {
- const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(u.id, date, planId);
- if (existing) {
- db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
- actions.push({ user_id: u.id, action: 'removed' });
- } else {
- db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, u.id, date, '');
- actions.push({ user_id: u.id, action: 'added' });
- }
- }
- notifyPlanUsers(planId, req.user.id);
- return res.json({ action: 'toggled_all', actions });
- }
-
// Allow toggling for another user if they are in the same plan
let userId = req.user.id;
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
+ const planUsers = getPlanUsers(planId);
const tid = parseInt(target_user_id);
if (!planUsers.find(u => u.id === tid)) {
return res.status(403).json({ error: 'User not in plan' });
@@ -484,11 +466,11 @@ router.post('/entries/toggle', (req, res) => {
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
- notifyPlanUsers(planId, req.user.id);
+ notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'removed' });
} else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
- notifyPlanUsers(planId, req.user.id);
+ notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' });
}
});
@@ -499,13 +481,13 @@ router.post('/entries/company-holiday', (req, res) => {
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
if (existing) {
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
- notifyPlanUsers(planId, req.user.id);
+ notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'removed' });
} else {
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
// Remove any vacation entries on this date
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
- notifyPlanUsers(planId, req.user.id);
+ notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' });
}
});
@@ -562,7 +544,7 @@ router.put('/stats/:year', (req, res) => {
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`).run(userId, planId, year, vacation_days);
- notifyPlanUsers(planId, req.user.id);
+ notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ success: true });
});
diff --git a/server/src/routes/weather.js b/server/src/routes/weather.js
index afd7d7a..86ed827 100644
--- a/server/src/routes/weather.js
+++ b/server/src/routes/weather.js
@@ -1,6 +1,5 @@
const express = require('express');
const fetch = require('node-fetch');
-const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
@@ -10,11 +9,12 @@ const weatherCache = new Map();
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
+const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours (historical data doesn't change)
-function cacheKey(lat, lng, date, units) {
+function cacheKey(lat, lng, date) {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
- return `${rlat}_${rlng}_${date || 'current'}_${units}`;
+ return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key) {
@@ -30,46 +30,123 @@ function getCached(key) {
function setCache(key, data, ttlMs) {
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
+
+// WMO weather code mapping → condition string used by client icon map
+const WMO_MAP = {
+ 0: 'Clear',
+ 1: 'Clear', // mainly clear
+ 2: 'Clouds', // partly cloudy
+ 3: 'Clouds', // overcast
+ 45: 'Fog',
+ 48: 'Fog',
+ 51: 'Drizzle',
+ 53: 'Drizzle',
+ 55: 'Drizzle',
+ 56: 'Drizzle', // freezing drizzle
+ 57: 'Drizzle',
+ 61: 'Rain',
+ 63: 'Rain',
+ 65: 'Rain', // heavy rain
+ 66: 'Rain', // freezing rain
+ 67: 'Rain',
+ 71: 'Snow',
+ 73: 'Snow',
+ 75: 'Snow',
+ 77: 'Snow', // snow grains
+ 80: 'Rain', // rain showers
+ 81: 'Rain',
+ 82: 'Rain',
+ 85: 'Snow', // snow showers
+ 86: 'Snow',
+ 95: 'Thunderstorm',
+ 96: 'Thunderstorm',
+ 99: 'Thunderstorm',
+};
+
+const WMO_DESCRIPTION_DE = {
+ 0: 'Klar',
+ 1: 'Überwiegend klar',
+ 2: 'Teilweise bewölkt',
+ 3: 'Bewölkt',
+ 45: 'Nebel',
+ 48: 'Nebel mit Reif',
+ 51: 'Leichter Nieselregen',
+ 53: 'Nieselregen',
+ 55: 'Starker Nieselregen',
+ 56: 'Gefrierender Nieselregen',
+ 57: 'Starker gefr. Nieselregen',
+ 61: 'Leichter Regen',
+ 63: 'Regen',
+ 65: 'Starker Regen',
+ 66: 'Gefrierender Regen',
+ 67: 'Starker gefr. Regen',
+ 71: 'Leichter Schneefall',
+ 73: 'Schneefall',
+ 75: 'Starker Schneefall',
+ 77: 'Schneekörner',
+ 80: 'Leichte Regenschauer',
+ 81: 'Regenschauer',
+ 82: 'Starke Regenschauer',
+ 85: 'Leichte Schneeschauer',
+ 86: 'Starke Schneeschauer',
+ 95: 'Gewitter',
+ 96: 'Gewitter mit Hagel',
+ 99: 'Starkes Gewitter mit Hagel',
+};
+
+const WMO_DESCRIPTION_EN = {
+ 0: 'Clear sky',
+ 1: 'Mainly clear',
+ 2: 'Partly cloudy',
+ 3: 'Overcast',
+ 45: 'Fog',
+ 48: 'Rime fog',
+ 51: 'Light drizzle',
+ 53: 'Drizzle',
+ 55: 'Heavy drizzle',
+ 56: 'Freezing drizzle',
+ 57: 'Heavy freezing drizzle',
+ 61: 'Light rain',
+ 63: 'Rain',
+ 65: 'Heavy rain',
+ 66: 'Freezing rain',
+ 67: 'Heavy freezing rain',
+ 71: 'Light snowfall',
+ 73: 'Snowfall',
+ 75: 'Heavy snowfall',
+ 77: 'Snow grains',
+ 80: 'Light rain showers',
+ 81: 'Rain showers',
+ 82: 'Heavy rain showers',
+ 85: 'Light snow showers',
+ 86: 'Heavy snow showers',
+ 95: 'Thunderstorm',
+ 96: 'Thunderstorm with hail',
+ 99: 'Severe thunderstorm with hail',
+};
+
+// Estimate weather condition from average temperature + precipitation
+function estimateCondition(tempAvg, precipMm) {
+ if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
+ if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
+ if (precipMm > 0.3) return 'Clouds';
+ return tempAvg > 15 ? 'Clear' : 'Clouds';
+}
// -------------------------------------------------------
-function formatItem(item) {
- return {
- temp: Math.round(item.main.temp),
- feels_like: Math.round(item.main.feels_like),
- humidity: item.main.humidity,
- main: item.weather[0]?.main || '',
- description: item.weather[0]?.description || '',
- icon: item.weather[0]?.icon || '',
- };
-}
-
-// GET /api/weather?lat=&lng=&date=&units=metric
+// GET /api/weather?lat=&lng=&date=&lang=de
router.get('/', authenticate, async (req, res) => {
- const { lat, lng, date, units = 'metric' } = req.query;
+ const { lat, lng, date, lang = 'de' } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
}
- // User's own key, or fall back to admin's key
- let key = null;
- const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
- if (user?.openweather_api_key) {
- key = user.openweather_api_key;
- } else {
- const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get();
- key = admin?.openweather_api_key || null;
- }
- if (!key) {
- return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
- }
-
- const ck = cacheKey(lat, lng, date, units);
+ const ck = cacheKey(lat, lng, date);
try {
- // If a date is requested, try the 5-day forecast first
+ // ── Forecast for a specific date ──
if (date) {
- // Check cache
const cached = getCached(ck);
if (cached) return res.json(cached);
@@ -77,49 +154,122 @@ router.get('/', authenticate, async (req, res) => {
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
- // Within 5-day forecast window
- if (diffDays >= -1 && diffDays <= 5) {
- const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
+ // Within 16-day forecast window → real forecast
+ if (diffDays >= -1 && diffDays <= 16) {
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const data = await response.json();
- if (!response.ok) {
- return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
+ if (!response.ok || data.error) {
+ return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
}
- const filtered = (data.list || []).filter(item => {
- const itemDate = new Date(item.dt * 1000);
- return itemDate.toDateString() === targetDate.toDateString();
- });
+ const dateStr = targetDate.toISOString().slice(0, 10);
+ const idx = (data.daily?.time || []).indexOf(dateStr);
+
+ if (idx !== -1) {
+ const code = data.daily.weathercode[idx];
+ const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
+
+ const result = {
+ temp: Math.round((data.daily.temperature_2m_max[idx] + data.daily.temperature_2m_min[idx]) / 2),
+ temp_max: Math.round(data.daily.temperature_2m_max[idx]),
+ temp_min: Math.round(data.daily.temperature_2m_min[idx]),
+ main: WMO_MAP[code] || 'Clouds',
+ description: descriptions[code] || '',
+ type: 'forecast',
+ };
- if (filtered.length > 0) {
- const midday = filtered.find(item => {
- const hour = new Date(item.dt * 1000).getHours();
- return hour >= 11 && hour <= 14;
- }) || filtered[0];
- const result = formatItem(midday);
setCache(ck, result, TTL_FORECAST_MS);
return res.json(result);
}
+ // Forecast didn't include this date — fall through to climate
}
- // Outside forecast window — no data available
+ // Beyond forecast range or forecast gap → historical climate average
+ if (diffDays > -1) {
+ const month = targetDate.getMonth() + 1;
+ const day = targetDate.getDate();
+ // Query a 5-day window around the target date for smoother averages (using last year as reference)
+ const refYear = targetDate.getFullYear() - 1;
+ const startDate = new Date(refYear, month - 1, day - 2);
+ const endDate = new Date(refYear, month - 1, day + 2);
+ const startStr = startDate.toISOString().slice(0, 10);
+ const endStr = endDate.toISOString().slice(0, 10);
+
+ const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
+ const response = await fetch(url);
+ const data = await response.json();
+
+ if (!response.ok || data.error) {
+ return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API Fehler' });
+ }
+
+ const daily = data.daily;
+ if (!daily || !daily.time || daily.time.length === 0) {
+ return res.json({ error: 'no_forecast' });
+ }
+
+ // Average across the window
+ let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
+ for (let i = 0; i < daily.time.length; i++) {
+ if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
+ sumMax += daily.temperature_2m_max[i];
+ sumMin += daily.temperature_2m_min[i];
+ sumPrecip += daily.precipitation_sum[i] || 0;
+ count++;
+ }
+ }
+
+ if (count === 0) {
+ return res.json({ error: 'no_forecast' });
+ }
+
+ const avgMax = sumMax / count;
+ const avgMin = sumMin / count;
+ const avgTemp = (avgMax + avgMin) / 2;
+ const avgPrecip = sumPrecip / count;
+ const main = estimateCondition(avgTemp, avgPrecip);
+
+ const result = {
+ temp: Math.round(avgTemp),
+ temp_max: Math.round(avgMax),
+ temp_min: Math.round(avgMin),
+ main,
+ description: '',
+ type: 'climate',
+ };
+
+ setCache(ck, result, TTL_CLIMATE_MS);
+ return res.json(result);
+ }
+
+ // Past dates beyond yesterday
return res.json({ error: 'no_forecast' });
}
- // No date — return current weather
+ // ── Current weather (no date) ──
const cached = getCached(ck);
if (cached) return res.json(cached);
- const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
- if (!response.ok) {
- return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
+ if (!response.ok || data.error) {
+ return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
}
- const result = formatItem(data);
+ const code = data.current.weathercode;
+ const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
+
+ const result = {
+ temp: Math.round(data.current.temperature_2m),
+ main: WMO_MAP[code] || 'Clouds',
+ description: descriptions[code] || '',
+ type: 'current',
+ };
+
setCache(ck, result, TTL_CURRENT_MS);
res.json(result);
} catch (err) {
diff --git a/server/src/websocket.js b/server/src/websocket.js
index 8347638..af801cb 100644
--- a/server/src/websocket.js
+++ b/server/src/websocket.js
@@ -141,10 +141,12 @@ function broadcast(tripId, eventType, payload, excludeSid) {
}
}
-function broadcastToUser(userId, payload) {
+function broadcastToUser(userId, payload, excludeSid) {
if (!wss) return;
+ const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of wss.clients) {
if (ws.readyState !== 1) continue;
+ if (excludeNum && socketId.get(ws) === excludeNum) continue;
const user = socketUser.get(ws);
if (user && user.id === userId) {
ws.send(JSON.stringify(payload));