feat: implement notification system with polling and event handling; add device ID middleware and cleanup logic

This commit is contained in:
2025-11-13 18:10:50 +01:00
parent 6b5ae7f578
commit a8c7b859a8
19 changed files with 354 additions and 25 deletions
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router();
@@ -23,6 +24,7 @@ router.post('/', async (req: Request, res: Response) => {
[id, name, fieldType, fieldOptions, alignment ?? 'start', listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]);
await enqueueNotification(dbAdapter, (req as any).deviceId, 'created', 'field', id, listId, Date.now());
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create field' });
@@ -44,6 +46,7 @@ router.put('/:id', async (req: Request, res: Response) => {
}
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [req.params.id]);
await enqueueNotification(dbAdapter, (req as any).deviceId, 'updated', 'field', req.params.id, (field as any)?.listId ?? (field as any)?.listid, updatedAt);
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update field' });
@@ -85,6 +88,7 @@ router.delete('/:id', async (req: Request, res: Response) => {
}
}
await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'field', req.params.id, (field as any).listId ?? (field as any).listid, updatedAt);
res.json({ message: 'Field deleted successfully', cascadeItemsDeleted: remainingCount === 0 });
} catch (error) {
res.status(500).json({ error: 'Failed to delete field' });
+16 -1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router();
@@ -33,6 +34,7 @@ router.post('/', async (req: Request, res: Response) => {
[id, listId, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]);
await enqueueNotification(dbAdapter, (req as any).deviceId, 'created', 'item', id, listId, Date.now());
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create item' });
@@ -48,6 +50,14 @@ router.post('/values', async (req: Request, res: Response) => {
[id, itemId, fieldId, value, updatedAt, value, updatedAt]
);
const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]);
// Notify list content updated (coarse event) using field's listId
try {
const field = await dbAdapter.queryOne('SELECT listId FROM fields WHERE id = ?', [fieldId]);
const listId = field ? ((field as any).listId ?? (field as any).listid) : undefined;
if (listId) {
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
}
} catch {}
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
@@ -68,6 +78,7 @@ router.put('/:id', async (req: Request, res: Response) => {
}
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
await enqueueNotification(dbAdapter, (req as any).deviceId, 'updated', 'item', req.params.id, (item as any)?.listId ?? (item as any)?.listid, updatedAt);
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update item' });
@@ -86,7 +97,11 @@ router.delete('/:id', async (req: Request, res: Response) => {
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
try {
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
const listId = item ? ((item as any).listId ?? (item as any).listid) : undefined;
await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'item', req.params.id, listId, updatedAt);
} catch {}
res.json({ message: 'Item deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete item' });
+5 -1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router();
@@ -37,6 +38,8 @@ router.post('/', async (req: Request, res: Response) => {
[id, name, createdAt, updatedAt, isDeleted ? 1 : 0]
);
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]);
// Notify others
await enqueueNotification(dbAdapter, (req as any).deviceId, 'created', 'list', id, id, Date.now());
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create list' });
@@ -56,6 +59,7 @@ router.put('/:id', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'List not found' });
}
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]);
await enqueueNotification(dbAdapter, (req as any).deviceId, 'updated', 'list', req.params.id, req.params.id, updatedAt);
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update list' });
@@ -74,7 +78,7 @@ router.delete('/:id', async (req: Request, res: Response) => {
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
await enqueueNotification(dbAdapter, (req as any).deviceId, 'deleted', 'list', req.params.id, req.params.id, updatedAt);
res.json({ message: 'List deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete list' });
@@ -0,0 +1,44 @@
import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
const router = Router();
// GET /api/notifications/poll?since=timestampMs
router.get('/notifications/poll', async (req: Request, res: Response) => {
try {
const sinceRaw = (req.query.since as string) || '0';
const since = Number(sinceRaw);
const deviceId = (req as any).deviceId as string | undefined;
const params: any[] = [isFinite(since) && since > 0 ? since : 0];
let sql = 'SELECT * FROM notifications WHERE createdAt > ?';
if (deviceId && deviceId.length > 0) {
sql += ' AND (deviceIdOrigin IS NULL OR deviceIdOrigin <> ?)';
params.push(deviceId);
}
sql += ' ORDER BY createdAt ASC LIMIT 500';
const rows = await dbAdapter.queryAll(sql, params);
// Normalize keys in case of differing DB casing
const pick = (obj: any, a: string, b: string) => obj[a] ?? obj[b];
const events = (rows as any[]).map(r => ({
id: r.id,
deviceIdOrigin: pick(r, 'deviceIdOrigin', 'deviceidorigin') || undefined,
eventType: pick(r, 'eventType', 'eventtype'),
entityType: pick(r, 'entityType', 'entitytype'),
entityId: pick(r, 'entityId', 'entityid') || undefined,
listId: pick(r, 'listId', 'listid') || undefined,
createdAt: pick(r, 'createdAt', 'createdat')
}));
res.json({ notifications: events, serverTimestamp: Date.now() });
} catch (e) {
console.error('[NOTIF] Poll failed:', e);
res.status(500).json({ error: 'Failed to poll notifications' });
}
});
export default router;
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router();
@@ -66,6 +67,7 @@ const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value,
router.post('/sync', async (req: Request, res: Response) => {
try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
const deviceId = (req as any).deviceId as string | undefined;
// Track stats
syncStats.totalSyncs++;
@@ -122,6 +124,11 @@ router.post('/sync', async (req: Request, res: Response) => {
list.updatedAt,
list.isDeleted ? 1 : 0
]);
// enqueue list-level notifications
try {
const ev = list.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, list.updatedAt);
} catch {}
// If a list was deleted, cascade the deletion to child records on server
// - mark fields and items as deleted (tombstones) so other clients learn about them
// - remove item_values belonging to items under this list (no tombstone support for values)
@@ -151,6 +158,10 @@ router.post('/sync', async (req: Request, res: Response) => {
field.updatedAt,
field.isDeleted ? 1 : 0
]);
try {
const ev = field.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, field.updatedAt);
} catch {}
if (field.isDeleted) {
// Remove item_values for this field
try {
@@ -184,6 +195,10 @@ router.post('/sync', async (req: Request, res: Response) => {
item.updatedAt,
item.isDeleted ? 1 : 0
]);
try {
const ev = item.isDeleted ? 'deleted' : (lastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, item.updatedAt);
} catch {}
// If an item was deleted, remove its values (no tombstone support for values)
if (item.isDeleted) {
try {
@@ -261,6 +276,13 @@ router.post('/sync', async (req: Request, res: Response) => {
value.value,
value.updatedAt
]);
// Coarse list content update notification
try {
const lId = fieldToList[value.fieldId];
if (lId) {
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, value.updatedAt);
}
} catch {}
} catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') {