diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle index 4d2adab..a998794 100644 --- a/CollabTableAndroid/app/build.gradle +++ b/CollabTableAndroid/app/build.gradle @@ -77,6 +77,7 @@ dependencies { implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + kapt 'com.squareup.retrofit2:retrofit:2.9.0' // Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt index 767fd86..9e9b48a 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt @@ -13,6 +13,7 @@ import androidx.work.WorkManager import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.notifications.NotificationHelper +import com.collabtable.app.notifications.NotificationPoller import com.collabtable.app.work.ListChangeNotificationWorker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,11 +43,18 @@ class CollabTableApplication : Application() { // Schedule periodic background check for list changes to surface notifications scheduleListChangeWorker() + // Start foreground notification polling loop (respects user interval) + NotificationPoller.start(this) + // Auto-clear notifications when app comes to foreground ProcessLifecycleOwner.get().lifecycle.addObserver( object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { NotificationHelper.clearListEventNotifications(this@CollabTableApplication) + NotificationPoller.start(this@CollabTableApplication) + } + override fun onStop(owner: LifecycleOwner) { + NotificationPoller.stop() } }, ) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt index 5416566..560b814 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -43,10 +43,20 @@ object ApiClient { 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 = OkHttpClient .Builder() .addInterceptor(authInterceptor) + .addInterceptor(deviceIdInterceptor) .addInterceptor(loggingInterceptor) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiModels.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiModels.kt new file mode 100644 index 0000000..9171df1 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiModels.kt @@ -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, + val serverTimestamp: Long, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt index 3e77b48..1012e1c 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt @@ -7,6 +7,8 @@ import com.collabtable.app.data.model.ItemValue import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST +import retrofit2.http.GET +import retrofit2.http.Query data class SyncRequest( val lastSyncTimestamp: Long, @@ -29,4 +31,9 @@ interface CollabTableApi { suspend fun sync( @Body request: SyncRequest, ): Response + + @GET("notifications/poll") + suspend fun pollNotifications( + @Query("since") since: Long, + ): Response } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index 7a58e10..184a9bf 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -258,8 +258,18 @@ class PreferencesManager( 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 { 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_SERVER_PASSWORD = "server_password" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt new file mode 100644 index 0000000..b58ce57 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/notifications/NotificationPoller.kt @@ -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) + } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index 2e7efed..ca4af15 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -537,6 +537,7 @@ fun ListDetailScreen( } } + // Render content states outside the horizontalScroll so empty state centers relative to viewport when { isLoading && stableItems.isEmpty() -> { Box( @@ -545,6 +546,7 @@ fun ListDetailScreen( ) { androidx.compose.material3.CircularProgressIndicator() } } stableItems.isEmpty() -> { + // Use full-width Box + centered text (header remains above inside scroll container) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -553,7 +555,9 @@ fun ListDetailScreen( text = stringResource(R.string.no_items), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), textAlign = TextAlign.Center, ) } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt index f866744..0056de9 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -128,16 +128,8 @@ class ListDetailViewModel( return state.isAtLeast(Lifecycle.State.STARTED) } - private fun maybeNotifyListContentUpdated() { - val prefs = - 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) - } - } + // Local changes should not notify this same device; remote-change notification (future) will be triggered elsewhere. + private fun maybeNotifyListContentUpdated() { /* intentionally disabled for local-origin changes */ } fun renameList(newName: String) { viewModelScope.launch { @@ -202,8 +194,7 @@ class ListDetailViewModel( } } - performSync() - maybeNotifyListContentUpdated() + performSync() // no local notification } } @@ -224,8 +215,7 @@ class ListDetailViewModel( database.listDao().updateList(l.copy(updatedAt = ts)) } } - performSync() - maybeNotifyListContentUpdated() + performSync() // no local notification } } @@ -252,8 +242,7 @@ class ListDetailViewModel( database.listDao().updateList(l.copy(updatedAt = ts)) } } - performSync() - maybeNotifyListContentUpdated() + performSync() // no local notification } } } @@ -319,8 +308,7 @@ class ListDetailViewModel( database.listDao().updateList(l.copy(updatedAt = timestamp)) } } - performSync() - maybeNotifyListContentUpdated() + performSync() // no local notification } } @@ -355,8 +343,7 @@ class ListDetailViewModel( database.listDao().updateList(l.copy(updatedAt = timestamp)) } } - performSync() - maybeNotifyListContentUpdated() + performSync() // no local notification } } diff --git a/CollabTableServer/src/database.ts b/CollabTableServer/src/database.ts deleted file mode 100644 index 0ce2291..0000000 --- a/CollabTableServer/src/database.ts +++ /dev/null @@ -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. diff --git a/CollabTableServer/src/db.ts b/CollabTableServer/src/db.ts index 9aafd9b..f47d34b 100644 --- a/CollabTableServer/src/db.ts +++ b/CollabTableServer/src/db.ts @@ -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_item_values_itemId ON item_values(itemId); 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) 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_item_values_itemId ON item_values(itemId);`); 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 await client.query(`ALTER TABLE fields ADD COLUMN IF NOT EXISTS alignment TEXT NOT NULL DEFAULT 'start';`); await client.query('COMMIT'); diff --git a/CollabTableServer/src/index.ts b/CollabTableServer/src/index.ts index 9b19172..f37fe42 100644 --- a/CollabTableServer/src/index.ts +++ b/CollabTableServer/src/index.ts @@ -3,11 +3,14 @@ import cors from 'cors'; import dotenv from 'dotenv'; import { initializeDatabase } from './db'; import { authenticatePassword } from './middleware/auth'; +import { deviceIdMiddleware } from './middleware/device'; import listRoutes from './routes/listRoutes'; import fieldRoutes from './routes/fieldRoutes'; import itemRoutes from './routes/itemRoutes'; import syncRoutes from './routes/syncRoutes'; import webRoutes from './routes/webRoutes'; +import notificationRoutes from './routes/notificationRoutes'; +import { startNotificationCleanup } from './notifications'; dotenv.config(); @@ -25,12 +28,15 @@ app.get('/health', (req, res) => { // Apply authentication middleware to all API routes app.use('/api', authenticatePassword); +// Attach device id for downstream routes +app.use('/api', deviceIdMiddleware); // Routes (protected by auth) app.use('/api/lists', listRoutes); app.use('/api/fields', fieldRoutes); app.use('/api/items', itemRoutes); app.use('/api', syncRoutes); +app.use('/api', notificationRoutes); // Web UI (no auth required, must be last to not interfere with API routes) app.use('/', webRoutes); @@ -46,6 +52,9 @@ app.use('/', webRoutes); console.log('- Items'); console.log('- ItemValues'); + // Start retention cleanup for notifications + startNotificationCleanup(); + // Start HTTP server (WebSocket removed) app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/CollabTableServer/src/middleware/device.ts b/CollabTableServer/src/middleware/device.ts new file mode 100644 index 0000000..96be62a --- /dev/null +++ b/CollabTableServer/src/middleware/device.ts @@ -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(); +} diff --git a/CollabTableServer/src/notifications.ts b/CollabTableServer/src/notifications.ts new file mode 100644 index 0000000..2c38c4f --- /dev/null +++ b/CollabTableServer/src/notifications.ts @@ -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?.(); +} diff --git a/CollabTableServer/src/routes/fieldRoutes.ts b/CollabTableServer/src/routes/fieldRoutes.ts index 8bec5c7..08fbba3 100644 --- a/CollabTableServer/src/routes/fieldRoutes.ts +++ b/CollabTableServer/src/routes/fieldRoutes.ts @@ -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' }); diff --git a/CollabTableServer/src/routes/itemRoutes.ts b/CollabTableServer/src/routes/itemRoutes.ts index f08282c..c71b0b7 100644 --- a/CollabTableServer/src/routes/itemRoutes.ts +++ b/CollabTableServer/src/routes/itemRoutes.ts @@ -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' }); diff --git a/CollabTableServer/src/routes/listRoutes.ts b/CollabTableServer/src/routes/listRoutes.ts index bf84dd6..bfcaa52 100644 --- a/CollabTableServer/src/routes/listRoutes.ts +++ b/CollabTableServer/src/routes/listRoutes.ts @@ -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' }); diff --git a/CollabTableServer/src/routes/notificationRoutes.ts b/CollabTableServer/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..667209f --- /dev/null +++ b/CollabTableServer/src/routes/notificationRoutes.ts @@ -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; diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index b91df38..f63d7a5 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -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') {