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