feat: add in-app notification system with real-time delivery
Introduces a full in-app notification system with three types (simple, boolean with server-side callbacks, navigate), three scopes (user, trip, admin), fan-out persistence per recipient, and real-time push via WebSocket. Includes a notification bell in the navbar, dropdown, dedicated /notifications page, and a dev-only admin tab for testing all notification variants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -491,6 +491,33 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
|
||||
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
|
||||
target INTEGER NOT NULL,
|
||||
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title_key TEXT NOT NULL,
|
||||
title_params TEXT DEFAULT '{}',
|
||||
text_key TEXT NOT NULL,
|
||||
text_params TEXT DEFAULT '{}',
|
||||
positive_text_key TEXT,
|
||||
negative_text_key TEXT,
|
||||
positive_callback TEXT,
|
||||
negative_callback TEXT,
|
||||
response TEXT CHECK(response IN ('positive', 'negative')),
|
||||
navigate_text_key TEXT,
|
||||
navigate_target TEXT,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -394,6 +394,30 @@ function createTables(db: Database.Database): void {
|
||||
ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
|
||||
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
|
||||
target INTEGER NOT NULL,
|
||||
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title_key TEXT NOT NULL,
|
||||
title_params TEXT DEFAULT '{}',
|
||||
text_key TEXT NOT NULL,
|
||||
text_params TEXT DEFAULT '{}',
|
||||
positive_text_key TEXT,
|
||||
negative_text_key TEXT,
|
||||
positive_callback TEXT,
|
||||
negative_callback TEXT,
|
||||
response TEXT CHECK(response IN ('positive', 'negative')),
|
||||
navigate_text_key TEXT,
|
||||
navigate_target TEXT,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -311,4 +311,44 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { createNotification } = require('../services/inAppNotifications');
|
||||
|
||||
router.post('/dev/test-notification', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { type, scope, target, title_key, text_key, title_params, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target } = req.body;
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
type: type || 'simple',
|
||||
scope: scope || 'user',
|
||||
target: target ?? authReq.user.id,
|
||||
sender_id: authReq.user.id,
|
||||
title_key: title_key || 'notifications.test.title',
|
||||
title_params: title_params || {},
|
||||
text_key: text_key || 'notifications.test.text',
|
||||
text_params: text_params || {},
|
||||
};
|
||||
|
||||
if (type === 'boolean') {
|
||||
input.positive_text_key = positive_text_key || 'notifications.test.accept';
|
||||
input.negative_text_key = negative_text_key || 'notifications.test.decline';
|
||||
input.positive_callback = positive_callback || { action: 'test_approve', payload: {} };
|
||||
input.negative_callback = negative_callback || { action: 'test_deny', payload: {} };
|
||||
} else if (type === 'navigate') {
|
||||
input.navigate_text_key = navigate_text_key || 'notifications.test.goThere';
|
||||
input.navigate_target = navigate_target || '/dashboard';
|
||||
}
|
||||
|
||||
try {
|
||||
const ids = createNotification(input);
|
||||
res.json({ success: true, notification_ids: ids });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,16 @@ import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp, testWebhook } from '../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../services/inAppNotifications';
|
||||
import * as prefsService from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -33,4 +43,87 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
|
||||
res.json(await testWebhook());
|
||||
});
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
|
||||
// GET /in-app — list notifications (paginated)
|
||||
router.get('/in-app', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const unreadOnly = req.query.unread_only === 'true';
|
||||
|
||||
const result = getNotifications(authReq.user.id, { limit, offset, unreadOnly });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /in-app/unread-count — badge count
|
||||
router.get('/in-app/unread-count', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = getUnreadCount(authReq.user.id);
|
||||
res.json({ count });
|
||||
});
|
||||
|
||||
// PUT /in-app/read-all — mark all read (must be before /:id routes)
|
||||
router.put('/in-app/read-all', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = markAllRead(authReq.user.id);
|
||||
res.json({ success: true, count });
|
||||
});
|
||||
|
||||
// DELETE /in-app/all — delete all (must be before /:id routes)
|
||||
router.delete('/in-app/all', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = deleteAll(authReq.user.id);
|
||||
res.json({ success: true, count });
|
||||
});
|
||||
|
||||
// PUT /in-app/:id/read — mark single read
|
||||
router.put('/in-app/:id/read', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = markRead(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /in-app/:id/unread — mark single unread
|
||||
router.put('/in-app/:id/unread', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = markUnread(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /in-app/:id — delete single
|
||||
router.delete('/in-app/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = deleteNotification(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /in-app/:id/respond — respond to a boolean notification
|
||||
router.post('/in-app/:id/respond', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const { response } = req.body;
|
||||
if (response !== 'positive' && response !== 'negative') {
|
||||
return res.status(400).json({ error: 'response must be "positive" or "negative"' });
|
||||
}
|
||||
|
||||
const result = await respondToBoolean(id, authReq.user.id, response);
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
res.json({ success: true, notification: result.notification });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -218,6 +218,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
notification_channel: notifChannel,
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
22
server/src/services/inAppNotificationActions.ts
Normal file
22
server/src/services/inAppNotificationActions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
type ActionHandler = (payload: Record<string, unknown>, respondingUserId: number) => Promise<void>;
|
||||
|
||||
const actionRegistry = new Map<string, ActionHandler>();
|
||||
|
||||
function registerAction(actionType: string, handler: ActionHandler): void {
|
||||
actionRegistry.set(actionType, handler);
|
||||
}
|
||||
|
||||
function getAction(actionType: string): ActionHandler | undefined {
|
||||
return actionRegistry.get(actionType);
|
||||
}
|
||||
|
||||
// Dev/test actions
|
||||
registerAction('test_approve', async () => {
|
||||
console.log('[notifications] Test approve action executed');
|
||||
});
|
||||
|
||||
registerAction('test_deny', async () => {
|
||||
console.log('[notifications] Test deny action executed');
|
||||
});
|
||||
|
||||
export { registerAction, getAction };
|
||||
332
server/src/services/inAppNotifications.ts
Normal file
332
server/src/services/inAppNotifications.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './notificationActions';
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
|
||||
interface BaseNotificationInput {
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
title_key: string;
|
||||
title_params?: Record<string, string>;
|
||||
text_key: string;
|
||||
text_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SimpleNotificationInput extends BaseNotificationInput {
|
||||
type: 'simple';
|
||||
}
|
||||
|
||||
interface BooleanNotificationInput extends BaseNotificationInput {
|
||||
type: 'boolean';
|
||||
positive_text_key: string;
|
||||
negative_text_key: string;
|
||||
positive_callback: { action: string; payload: Record<string, unknown> };
|
||||
negative_callback: { action: string; payload: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface NavigateNotificationInput extends BaseNotificationInput {
|
||||
type: 'navigate';
|
||||
navigate_text_key: string;
|
||||
navigate_target: string;
|
||||
}
|
||||
|
||||
type NotificationInput = SimpleNotificationInput | BooleanNotificationInput | NavigateNotificationInput;
|
||||
|
||||
interface NotificationRow {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
sender_username?: string | null;
|
||||
sender_avatar?: string | null;
|
||||
recipient_id: number;
|
||||
title_key: string;
|
||||
title_params: string;
|
||||
text_key: string;
|
||||
text_params: string;
|
||||
positive_text_key: string | null;
|
||||
negative_text_key: string | null;
|
||||
positive_callback: string | null;
|
||||
negative_callback: string | null;
|
||||
response: NotificationResponse | null;
|
||||
navigate_text_key: string | null;
|
||||
navigate_target: string | null;
|
||||
is_read: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
let userIds: number[] = [];
|
||||
|
||||
if (scope === 'trip') {
|
||||
const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(target) as { user_id: number } | undefined;
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(target) as { user_id: number }[];
|
||||
const ids = new Set<number>();
|
||||
if (owner) ids.add(owner.user_id);
|
||||
for (const m of members) ids.add(m.user_id);
|
||||
userIds = Array.from(ids);
|
||||
} else if (scope === 'user') {
|
||||
userIds = [target];
|
||||
} else if (scope === 'admin') {
|
||||
const admins = db.prepare('SELECT id FROM users WHERE role = ?').all('admin') as { id: number }[];
|
||||
userIds = admins.map(a => a.id);
|
||||
}
|
||||
|
||||
// Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit
|
||||
if (excludeUserId != null && scope !== 'user') {
|
||||
userIds = userIds.filter(id => id !== excludeUserId);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
function createNotification(input: NotificationInput): number[] {
|
||||
const recipients = resolveRecipients(input.scope, input.target, input.sender_id);
|
||||
if (recipients.length === 0) return [];
|
||||
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = stmt.run(
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
insertedIds.push(result.lastInsertRowid as number);
|
||||
}
|
||||
});
|
||||
|
||||
insert();
|
||||
|
||||
// Fetch sender info once for WS payloads
|
||||
const sender = input.sender_id
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(input.sender_id) as { username: string; avatar: string | null } | undefined)
|
||||
: null;
|
||||
|
||||
// Broadcast to each recipient
|
||||
for (let i = 0; i < insertedIds.length; i++) {
|
||||
const notificationId = insertedIds[i];
|
||||
const recipientId = recipients[i];
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
||||
if (!row) continue;
|
||||
|
||||
broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return insertedIds;
|
||||
}
|
||||
|
||||
function getNotifications(
|
||||
userId: number,
|
||||
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
|
||||
): { notifications: NotificationRow[]; total: number; unread_count: number } {
|
||||
const limit = Math.min(options.limit ?? 20, 50);
|
||||
const offset = options.offset ?? 0;
|
||||
const unreadOnly = options.unreadOnly ?? false;
|
||||
|
||||
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
|
||||
const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
${whereAliased}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(userId, limit, offset) as NotificationRow[];
|
||||
|
||||
const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number };
|
||||
const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number };
|
||||
|
||||
return { notifications: rows, total, unread_count };
|
||||
}
|
||||
|
||||
function getUnreadCount(userId: number): number {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
function markRead(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function markUnread(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function markAllRead(userId: number): number {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0').run(userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function deleteNotification(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('DELETE FROM notifications WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function deleteAll(userId: number): number {
|
||||
const result = db.prepare('DELETE FROM notifications WHERE recipient_id = ?').run(userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
async function respondToBoolean(
|
||||
notificationId: number,
|
||||
userId: number,
|
||||
response: NotificationResponse
|
||||
): Promise<{ success: boolean; error?: string; notification?: NotificationRow }> {
|
||||
const notification = db.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(notificationId, userId) as NotificationRow | undefined;
|
||||
|
||||
if (!notification) return { success: false, error: 'Notification not found' };
|
||||
if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' };
|
||||
if (notification.response !== null) return { success: false, error: 'Already responded' };
|
||||
|
||||
const callbackJson = response === 'positive' ? notification.positive_callback : notification.negative_callback;
|
||||
if (!callbackJson) return { success: false, error: 'No callback defined' };
|
||||
|
||||
let callback: { action: string; payload: Record<string, unknown> };
|
||||
try {
|
||||
callback = JSON.parse(callbackJson);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid callback format' };
|
||||
}
|
||||
|
||||
const handler = getAction(callback.action);
|
||||
if (!handler) return { success: false, error: `Unknown action: ${callback.action}` };
|
||||
|
||||
try {
|
||||
await handler(callback.payload, userId);
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
|
||||
}
|
||||
|
||||
// Atomic update — only updates if response is still NULL (prevents double-response)
|
||||
const result = db.prepare(
|
||||
'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL'
|
||||
).run(response, notificationId, userId);
|
||||
|
||||
if (result.changes === 0) return { success: false, error: 'Already responded' };
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
WHERE n.id = ?
|
||||
`).get(notificationId) as NotificationRow;
|
||||
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: updated });
|
||||
|
||||
return { success: true, notification: updated };
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
id: number;
|
||||
user_id: number;
|
||||
notify_trip_invite: number;
|
||||
notify_booking_change: number;
|
||||
notify_trip_reminder: number;
|
||||
notify_webhook: number;
|
||||
}
|
||||
|
||||
interface PreferencesUpdate {
|
||||
notify_trip_invite?: boolean;
|
||||
notify_booking_change?: boolean;
|
||||
notify_trip_reminder?: boolean;
|
||||
notify_webhook?: boolean;
|
||||
}
|
||||
|
||||
function getPreferences(userId: number): NotificationPreferences {
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences | undefined;
|
||||
if (!prefs) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
function updatePreferences(userId: number, updates: PreferencesUpdate): NotificationPreferences {
|
||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
||||
}
|
||||
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = updates;
|
||||
|
||||
db.prepare(`UPDATE notification_preferences SET
|
||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||
notify_webhook = COALESCE(?, notify_webhook)
|
||||
WHERE user_id = ?`).run(
|
||||
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
|
||||
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
|
||||
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
|
||||
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
|
||||
userId
|
||||
);
|
||||
|
||||
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
|
||||
}
|
||||
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
};
|
||||
|
||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||
Reference in New Issue
Block a user