feat: implement notification system with polling and event handling; add device ID middleware and cleanup logic
This commit is contained in:
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user