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
View File
@@ -77,6 +77,7 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
kapt 'com.squareup.retrofit2:retrofit:2.9.0'
// Coroutines // Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
@@ -13,6 +13,7 @@ import androidx.work.WorkManager
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.notifications.NotificationHelper import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.notifications.NotificationPoller
import com.collabtable.app.work.ListChangeNotificationWorker import com.collabtable.app.work.ListChangeNotificationWorker
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,11 +43,18 @@ class CollabTableApplication : Application() {
// Schedule periodic background check for list changes to surface notifications // Schedule periodic background check for list changes to surface notifications
scheduleListChangeWorker() scheduleListChangeWorker()
// Start foreground notification polling loop (respects user interval)
NotificationPoller.start(this)
// Auto-clear notifications when app comes to foreground // Auto-clear notifications when app comes to foreground
ProcessLifecycleOwner.get().lifecycle.addObserver( ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver { object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication) NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication)
}
override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop()
} }
}, },
) )
@@ -43,10 +43,20 @@ object ApiClient {
chain.proceed(newRequest) chain.proceed(newRequest)
} }
private val deviceIdInterceptor =
Interceptor { chain ->
val request = chain.request()
val devId = context?.let { PreferencesManager.getInstance(it).getDeviceId() }
val newReq =
if (!devId.isNullOrBlank()) request.newBuilder().header("x-device-id", devId).build() else request
chain.proceed(newReq)
}
private val okHttpClient = private val okHttpClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(deviceIdInterceptor)
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
@@ -0,0 +1,16 @@
package com.collabtable.app.data.api
data class NotificationEvent(
val id: String,
val deviceIdOrigin: String?,
val eventType: String,
val entityType: String,
val entityId: String?,
val listId: String?,
val createdAt: Long,
)
data class PollResponse(
val notifications: List<NotificationEvent>,
val serverTimestamp: Long,
)
@@ -7,6 +7,8 @@ import com.collabtable.app.data.model.ItemValue
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.GET
import retrofit2.http.Query
data class SyncRequest( data class SyncRequest(
val lastSyncTimestamp: Long, val lastSyncTimestamp: Long,
@@ -29,4 +31,9 @@ interface CollabTableApi {
suspend fun sync( suspend fun sync(
@Body request: SyncRequest, @Body request: SyncRequest,
): Response<SyncResponse> ): Response<SyncResponse>
@GET("notifications/poll")
suspend fun pollNotifications(
@Query("since") since: Long,
): Response<PollResponse>
} }
@@ -258,8 +258,18 @@ class PreferencesManager(
prefs.edit().putString(key, json.toString()).apply() prefs.edit().putString(key, json.toString()).apply()
} }
// Generate and cache a stable device id (UUID) for this install
fun getDeviceId(): String {
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) return existing
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
companion object { companion object {
private const val KEY_SERVER_URL = "server_url" private const val KEY_SERVER_URL = "server_url"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_FIRST_RUN = "first_run" private const val KEY_FIRST_RUN = "first_run"
private const val KEY_SERVER_PASSWORD = "server_password" private const val KEY_SERVER_PASSWORD = "server_password"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
@@ -0,0 +1,95 @@
package com.collabtable.app.notifications
import android.content.Context
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.CollabTableApi
import com.collabtable.app.data.api.NotificationEvent
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
object NotificationPoller {
@Volatile private var job: Job? = null
fun start(context: Context) {
if (job?.isActive == true) return
val appCtx = context.applicationContext
val prefs = PreferencesManager.getInstance(appCtx)
val api = ApiClient.api
val db = CollabTableDatabase.getDatabase(appCtx)
job = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
try {
// Skip if password missing (auth disabled)
val pwd = prefs.getServerPassword()?.trim()
if (pwd.isNullOrBlank() || pwd == "\$password") {
delay(2_000)
continue
}
val since = prefs.getLastListNotifyCheckTimestamp()
val resp = api.pollNotifications(since)
if (resp.isSuccessful) {
val body = resp.body()
val events = body?.notifications.orEmpty()
if (events.isNotEmpty()) {
// Map and emit based on preferences
events.forEach { ev ->
val listId = ev.listId ?: return@forEach
val list = db.listDao().getListById(listId) ?: return@forEach
handleEvent(appCtx, ev, list.name, prefs)
}
}
// Advance checkpoint to server timestamp to avoid re-processing
val stamp = body?.serverTimestamp ?: System.currentTimeMillis()
prefs.setLastListNotifyCheckTimestamp(stamp)
} else if (resp.code() == 401) {
// Unauthorized; back off by advancing timestamp minimally to avoid tight loop
delay(5_000)
}
} catch (e: Exception) {
try { Logger.w("NotifPoll", "Polling failed: ${e.message}") } catch (_: Exception) {}
// brief backoff
delay(1_500)
}
val interval = prefs.syncPollIntervalMs.value
delay(interval)
}
}
}
fun stop() {
job?.cancel()
job = null
}
private fun handleEvent(
context: Context,
ev: NotificationEvent,
listName: String,
prefs: PreferencesManager,
) {
// Respect notification switches by grouping semantics
when (ev.entityType.lowercase()) {
"list" -> when (ev.eventType.lowercase()) {
"created" -> if (prefs.notifyListAdded.value) NotificationHelper.showListAdded(context, ev.listId ?: "", listName)
"updated" -> if (prefs.notifyListEdited.value) NotificationHelper.showListEdited(context, ev.listId ?: "", listName)
"deleted" -> if (prefs.notifyListRemoved.value) NotificationHelper.showListRemoved(context, ev.listId ?: "", listName)
}
// Treat fields/items/value changes as content updates
"field", "item", "value" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
else -> when (ev.eventType.lowercase()) {
"listcontentupdated" -> if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
}
}
}
}
@@ -537,6 +537,7 @@ fun ListDetailScreen(
} }
} }
// Render content states outside the horizontalScroll so empty state centers relative to viewport
when { when {
isLoading && stableItems.isEmpty() -> { isLoading && stableItems.isEmpty() -> {
Box( Box(
@@ -545,6 +546,7 @@ fun ListDetailScreen(
) { androidx.compose.material3.CircularProgressIndicator() } ) { androidx.compose.material3.CircularProgressIndicator() }
} }
stableItems.isEmpty() -> { stableItems.isEmpty() -> {
// Use full-width Box + centered text (header remains above inside scroll container)
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -553,7 +555,9 @@ fun ListDetailScreen(
text = stringResource(R.string.no_items), text = stringResource(R.string.no_items),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -128,16 +128,8 @@ class ListDetailViewModel(
return state.isAtLeast(Lifecycle.State.STARTED) return state.isAtLeast(Lifecycle.State.STARTED)
} }
private fun maybeNotifyListContentUpdated() { // Local changes should not notify this same device; remote-change notification (future) will be triggered elsewhere.
val prefs = private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ }
com.collabtable.app.data.preferences.PreferencesManager
.getInstance(context)
if (prefs.notifyListContentUpdated.value && !isInForeground()) {
val name = _list.value?.name ?: "Table"
com.collabtable.app.notifications.NotificationHelper
.showListContentUpdated(context, listId, name)
}
}
fun renameList(newName: String) { fun renameList(newName: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -202,8 +194,7 @@ class ListDetailViewModel(
} }
} }
performSync() performSync() // no local notification
maybeNotifyListContentUpdated()
} }
} }
@@ -224,8 +215,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = ts)) database.listDao().updateList(l.copy(updatedAt = ts))
} }
} }
performSync() performSync() // no local notification
maybeNotifyListContentUpdated()
} }
} }
@@ -252,8 +242,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = ts)) database.listDao().updateList(l.copy(updatedAt = ts))
} }
} }
performSync() performSync() // no local notification
maybeNotifyListContentUpdated()
} }
} }
} }
@@ -319,8 +308,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = timestamp)) database.listDao().updateList(l.copy(updatedAt = timestamp))
} }
} }
performSync() performSync() // no local notification
maybeNotifyListContentUpdated()
} }
} }
@@ -355,8 +343,7 @@ class ListDetailViewModel(
database.listDao().updateList(l.copy(updatedAt = timestamp)) database.listDao().updateList(l.copy(updatedAt = timestamp))
} }
} }
performSync() performSync() // no local notification
maybeNotifyListContentUpdated()
} }
} }
-2
View File
@@ -1,2 +0,0 @@
// Legacy module removed. Database access has been migrated to src/db.ts.
// This file is intentionally left blank to avoid accidental imports.
+27
View File
@@ -94,6 +94,19 @@ class SqliteAdapter implements DBAdapter {
CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId); CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);
CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId); CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);
CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId); CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);
-- Notification events table (for remote-change notifications)
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
deviceIdOrigin TEXT,
eventType TEXT NOT NULL,
entityType TEXT NOT NULL,
entityId TEXT,
listId TEXT,
createdAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notifications_createdAt ON notifications(createdAt);
CREATE INDEX IF NOT EXISTS idx_notifications_listId ON notifications(listId);
`); `);
// Attempt to add alignment column if upgrading an existing DB (ignore error if exists) // Attempt to add alignment column if upgrading an existing DB (ignore error if exists)
try { try {
@@ -204,6 +217,20 @@ class PostgresAdapter implements DBAdapter {
await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`);
// Notifications table
await client.query(`
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
deviceIdOrigin TEXT,
eventType TEXT NOT NULL,
entityType TEXT NOT NULL,
entityId TEXT,
listId TEXT,
createdAt BIGINT NOT NULL
);
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_notifications_createdAt ON notifications(createdAt);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_notifications_listId ON notifications(listId);`);
// Migrate existing DBs: ensure alignment column exists // Migrate existing DBs: ensure alignment column exists
await client.query(`ALTER TABLE fields ADD COLUMN IF NOT EXISTS alignment TEXT NOT NULL DEFAULT 'start';`); await client.query(`ALTER TABLE fields ADD COLUMN IF NOT EXISTS alignment TEXT NOT NULL DEFAULT 'start';`);
await client.query('COMMIT'); await client.query('COMMIT');
+9
View File
@@ -3,11 +3,14 @@ import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { initializeDatabase } from './db'; import { initializeDatabase } from './db';
import { authenticatePassword } from './middleware/auth'; import { authenticatePassword } from './middleware/auth';
import { deviceIdMiddleware } from './middleware/device';
import listRoutes from './routes/listRoutes'; import listRoutes from './routes/listRoutes';
import fieldRoutes from './routes/fieldRoutes'; import fieldRoutes from './routes/fieldRoutes';
import itemRoutes from './routes/itemRoutes'; import itemRoutes from './routes/itemRoutes';
import syncRoutes from './routes/syncRoutes'; import syncRoutes from './routes/syncRoutes';
import webRoutes from './routes/webRoutes'; import webRoutes from './routes/webRoutes';
import notificationRoutes from './routes/notificationRoutes';
import { startNotificationCleanup } from './notifications';
dotenv.config(); dotenv.config();
@@ -25,12 +28,15 @@ app.get('/health', (req, res) => {
// Apply authentication middleware to all API routes // Apply authentication middleware to all API routes
app.use('/api', authenticatePassword); app.use('/api', authenticatePassword);
// Attach device id for downstream routes
app.use('/api', deviceIdMiddleware);
// Routes (protected by auth) // Routes (protected by auth)
app.use('/api/lists', listRoutes); app.use('/api/lists', listRoutes);
app.use('/api/fields', fieldRoutes); app.use('/api/fields', fieldRoutes);
app.use('/api/items', itemRoutes); app.use('/api/items', itemRoutes);
app.use('/api', syncRoutes); app.use('/api', syncRoutes);
app.use('/api', notificationRoutes);
// Web UI (no auth required, must be last to not interfere with API routes) // Web UI (no auth required, must be last to not interfere with API routes)
app.use('/', webRoutes); app.use('/', webRoutes);
@@ -46,6 +52,9 @@ app.use('/', webRoutes);
console.log('- Items'); console.log('- Items');
console.log('- ItemValues'); console.log('- ItemValues');
// Start retention cleanup for notifications
startNotificationCleanup();
// Start HTTP server (WebSocket removed) // Start HTTP server (WebSocket removed)
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`); console.log(`Server is running on port ${PORT}`);
@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
/**
* Extracts device identifier from headers/body and attaches it to req as `deviceId`.
* - Preferred: `x-device-id` header
* - Fallback: `req.body.deviceId` or `req.query.deviceId`
*/
export function deviceIdMiddleware(req: Request, _res: Response, next: NextFunction) {
const headerId = (req.headers['x-device-id'] || req.headers['X-Device-Id']) as string | undefined;
const bodyId = (req.body && typeof req.body === 'object') ? (req.body.deviceId as string | undefined) : undefined;
const queryId = (req.query?.deviceId as string | undefined);
const id = (headerId || bodyId || queryId || '').toString().trim();
(req as any).deviceId = id.length > 0 ? id : undefined;
next();
}
+53
View File
@@ -0,0 +1,53 @@
import { dbAdapter, DBAdapter } from './db';
import crypto from 'crypto';
export type NotificationEvent = {
id: string;
deviceIdOrigin?: string;
eventType: string; // created | updated | deleted | listContentUpdated | listEdited ...
entityType: string; // list | field | item | value
entityId?: string;
listId?: string;
createdAt: number; // ms epoch
};
function genId() {
try {
return crypto.randomUUID();
} catch {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
}
export async function enqueueNotification(
adapter: DBAdapter,
deviceIdOrigin: string | undefined,
eventType: string,
entityType: string,
entityId?: string,
listId?: string,
createdAt?: number
) {
const id = genId();
const ts = createdAt ?? Date.now();
await adapter.execute(
'INSERT INTO notifications (id, deviceIdOrigin, eventType, entityType, entityId, listId, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, deviceIdOrigin ?? null, eventType, entityType, entityId ?? null, listId ?? null, ts]
);
}
// Retention policy
const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const CLEAN_INTERVAL_MS = 60 * 60 * 1000; // hourly
export function startNotificationCleanup() {
const retentionMs = Number(process.env.NOTIF_RETENTION_MS || DEFAULT_RETENTION_MS);
setInterval(async () => {
try {
const cutoff = Date.now() - retentionMs;
await dbAdapter.execute('DELETE FROM notifications WHERE createdAt < ?', [cutoff]);
} catch (e) {
console.warn('[NOTIF] Retention cleanup failed:', e);
}
}, CLEAN_INTERVAL_MS).unref?.();
}
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db'; import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router(); 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] [id, name, fieldType, fieldOptions, alignment ?? 'start', listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
); );
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]); 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 }); res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create field' }); 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]); 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 }); res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update field' }); 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 }); res.json({ message: 'Field deleted successfully', cascadeItemsDeleted: remainingCount === 0 });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete field' }); res.status(500).json({ error: 'Failed to delete field' });
+16 -1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db'; import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router(); const router = Router();
@@ -33,6 +34,7 @@ router.post('/', async (req: Request, res: Response) => {
[id, listId, createdAt, updatedAt, isDeleted ? 1 : 0] [id, listId, createdAt, updatedAt, isDeleted ? 1 : 0]
); );
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]); 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 }); res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create item' }); 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] [id, itemId, fieldId, value, updatedAt, value, updatedAt]
); );
const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]); 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); res.json(itemValue);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to save item value' }); 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]); 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 }); res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update item' }); 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) { if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' }); 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' }); res.json({ message: 'Item deleted successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete item' }); res.status(500).json({ error: 'Failed to delete item' });
+5 -1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { dbAdapter } from '../db'; import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router(); const router = Router();
@@ -37,6 +38,8 @@ router.post('/', async (req: Request, res: Response) => {
[id, name, createdAt, updatedAt, isDeleted ? 1 : 0] [id, name, createdAt, updatedAt, isDeleted ? 1 : 0]
); );
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]); 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 }); res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create list' }); 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' }); return res.status(404).json({ error: 'List not found' });
} }
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]); 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 }); res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update list' }); 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) { if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' }); 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' }); res.json({ message: 'List deleted successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to delete list' }); 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 { Router, Request, Response } from 'express';
import { dbAdapter } from '../db'; import { dbAdapter } from '../db';
import { enqueueNotification } from '../notifications';
const router = Router(); 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) => { router.post('/sync', async (req: Request, res: Response) => {
try { try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body; const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
const deviceId = (req as any).deviceId as string | undefined;
// Track stats // Track stats
syncStats.totalSyncs++; syncStats.totalSyncs++;
@@ -122,6 +124,11 @@ router.post('/sync', async (req: Request, res: Response) => {
list.updatedAt, list.updatedAt,
list.isDeleted ? 1 : 0 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 // 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 // - 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) // - 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.updatedAt,
field.isDeleted ? 1 : 0 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) { if (field.isDeleted) {
// Remove item_values for this field // Remove item_values for this field
try { try {
@@ -184,6 +195,10 @@ router.post('/sync', async (req: Request, res: Response) => {
item.updatedAt, item.updatedAt,
item.isDeleted ? 1 : 0 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 an item was deleted, remove its values (no tombstone support for values)
if (item.isDeleted) { if (item.isDeleted) {
try { try {
@@ -261,6 +276,13 @@ router.post('/sync', async (req: Request, res: Response) => {
value.value, value.value,
value.updatedAt 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 (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync // Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') { if (err && err.code === '23503') {