feat: enhance notification handling and improve code structure across multiple components

This commit is contained in:
2026-06-03 17:30:24 +02:00
parent 18dbcd372b
commit ebe5fc13c4
14 changed files with 270 additions and 188 deletions
@@ -0,0 +1,28 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:Logger.kt$Logger$last != null &amp;&amp; last.level == level &amp;&amp; last.tag == tag &amp;&amp; last.message == message &amp;&amp; (now - last.timestamp) &lt;= DEDUPE_WINDOW_MS</ID>
<ID>ExplicitItLambdaParameter:ListsScreen.kt${ _, it -&gt; it.id }</ID>
<ID>ReturnCount:ListsViewModel.kt$ListsViewModel$suspend fun exportListToCsv( listId: String, listName: String, targetTreeUri: Uri? = null, ): Result&lt;String&gt;</ID>
<ID>ReturnCount:SyncRepository.kt$SyncRepository$private fun isNetworkAvailable(): Boolean</ID>
<ID>SwallowedException:ApiClient.kt$ApiClient$e: Exception</ID>
<ID>SwallowedException:ConnectionStatus.kt$e: Exception</ID>
<ID>SwallowedException:Field.kt$Field$e: Exception</ID>
<ID>SwallowedException:ListChangeNotificationWorker.kt$ListChangeNotificationWorker$e: Exception</ID>
<ID>SwallowedException:ListDetailScreen.kt$e: Exception</ID>
<ID>SwallowedException:PreferencesManager.kt$PreferencesManager$e: Exception</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: ConnectException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: Exception</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: NetworkOnMainThreadException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: SocketTimeoutException</ID>
<ID>SwallowedException:ServerSetupViewModel.kt$ServerSetupViewModel$e: UnknownHostException</ID>
<ID>UnusedParameter:ListDetailScreen.kt$onAlignmentChange: (String) -&gt; Unit = {}</ID>
<ID>UnusedParameter:ListDetailScreen.kt$onUpdateAlignment: (String, String) -&gt; Unit</ID>
<ID>UnusedParameter:ListsScreen.kt$onNavigateToLogs: () -&gt; Unit = {}</ID>
<ID>UnusedParameter:ListsScreen.kt$onNavigateToSettings: () -&gt; Unit</ID>
<ID>UnusedParameter:SettingsScreen.kt$onNavigateBack: () -&gt; Unit</ID>
<ID>UnusedPrivateMember:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FilterSortDialog( fields: List&lt;Field&gt;, currentSortField: Field?, currentSortAscending: Boolean, currentGroupByField: Field?, currentFilterField: Field?, currentFilterValue: String, onDismiss: () -&gt; Unit, onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -&gt; Unit, onClearAll: () -&gt; Unit, )</ID>
<ID>UnusedPrivateMember:ListDetailViewModel.kt$ListDetailViewModel$private fun isInForeground(): Boolean</ID>
</CurrentIssues>
</SmellBaseline>
@@ -53,6 +53,7 @@ class CollabTableApplication : Application() {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication)
}
override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop()
}
@@ -6,8 +6,8 @@ import com.collabtable.app.data.model.Item
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.POST
import retrofit2.http.Query
data class SyncRequest(
@@ -48,7 +48,11 @@ interface FieldDao {
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
@Query("UPDATE fields SET `order` = :newOrder, updatedAt = :timestamp WHERE id = :fieldId")
suspend fun updateFieldOrder(fieldId: String, newOrder: Int, timestamp: Long)
suspend fun updateFieldOrder(
fieldId: String,
newOrder: Int,
timestamp: Long,
)
// Reorder fields in a single transaction for clarity and consistency
@Transaction
@@ -16,8 +16,8 @@ class PreferencesManager(
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _isFirstRun = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.asStateFlow()
private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
@@ -67,7 +67,7 @@ class PreferencesManager(
fun setIsFirstRun(isFirstRun: Boolean) {
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
_isFirstRun.value = isFirstRun
_isFirstRunFlow.value = isFirstRun
}
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
@@ -272,7 +272,10 @@ class PreferencesManager(
fun getDeviceId(): String {
val existing = prefs.getString(KEY_DEVICE_ID, null)
if (!existing.isNullOrBlank()) return existing
val id = java.util.UUID.randomUUID().toString()
val id =
java.util.UUID
.randomUUID()
.toString()
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
return id
}
@@ -1,8 +1,12 @@
package com.collabtable.app.notifications
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.collabtable.app.R
object NotificationHelper {
@@ -34,6 +38,15 @@ object NotificationHelper {
id: Int,
builder: NotificationCompat.Builder,
) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
with(NotificationManagerCompat.from(context)) {
notify(id, builder.build())
}
@@ -2,7 +2,6 @@ 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
@@ -23,44 +22,48 @@ object NotificationPoller {
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)
}
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
}
// 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)
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)
}
} 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)
}
val interval = prefs.syncPollIntervalMs.value
delay(interval)
}
}
}
fun stop() {
@@ -76,20 +79,24 @@ object NotificationPoller {
) {
// 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)
}
"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) {
"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)
}
}
}
}
}
@@ -51,7 +51,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
@@ -226,6 +225,7 @@ fun ListDetailScreen(
floatingActionButton = {},
) { padding ->
// Apply filtering, sorting and grouping off the main thread and memoize the result
@Suppress("ProduceStateDoesNotAssignValue")
val transformed by produceState(
initialValue = TransformedItems(emptyList(), mapOf("_all" to emptyList())),
stableItems,
@@ -235,7 +235,7 @@ fun ListDetailScreen(
filterField,
filterValue,
) {
value =
val transformedItems =
withContext(Dispatchers.Default) {
transformItems(
items = stableItems,
@@ -246,6 +246,7 @@ fun ListDetailScreen(
filterValue = filterValue,
)
}
value = transformedItems
}
val processedItems = transformed.processed
@@ -550,17 +551,19 @@ fun ListDetailScreen(
}
stableItems.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxSize(),
modifier =
Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.no_items),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
textAlign = TextAlign.Center,
)
}
@@ -1046,8 +1049,7 @@ fun ItemRow(
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
)
.fillMaxHeight()
).fillMaxHeight()
// Consistent minimum top/bottom padding regardless of row height
.padding(horizontal = 8.dp, vertical = 8.dp),
// Center horizontally based on alignment while always vertically centering
@@ -1105,7 +1107,8 @@ fun ItemRow(
}
com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> {
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
-> {
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
@@ -2365,9 +2368,10 @@ fun FieldInput(
com.collabtable.app.data.model.FieldType.MULTI_SELECT -> {
val options = field.getSelectOptions()
var expanded by remember { mutableStateOf(false) }
val selectedOptions = remember(value) {
value.split(", ").filter { it.isNotBlank() }.toSet()
}
val selectedOptions =
remember(value) {
value.split(", ").filter { it.isNotBlank() }.toSet()
}
ExposedDropdownMenuBox(
expanded = expanded,
@@ -2402,11 +2406,12 @@ fun FieldInput(
}
},
onClick = {
val newSelected = if (isSelected) {
selectedOptions - option
} else {
selectedOptions + option
}
val newSelected =
if (isSelected) {
selectedOptions - option
} else {
selectedOptions + option
}
val orderedNewSelected = options.filter { it in newSelected }
onValueChange(orderedNewSelected.joinToString(", "))
},
@@ -4083,11 +4088,3 @@ private fun RenameListDialog(
},
)
}
@@ -3,7 +3,9 @@
package com.collabtable.app.ui.screens
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -29,9 +31,6 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Share
import android.net.Uri
import android.content.Intent
import androidx.documentfile.provider.DocumentFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@@ -60,6 +59,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
@@ -349,32 +349,39 @@ fun ListsScreen(
}
// Export dialog (top-level) separate from create/edit/delete dialogs
listToExport?.let { list ->
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
// Take persistable permission for potential reuse during this session
try {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
} catch (_: Exception) { }
exportTargetUri = uri
val folderPicker =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
// Take persistable permission for potential reuse during this session
try {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
} catch (_: Exception) {
}
exportTargetUri = uri
}
}
}
ExportListDialog(
list = list,
selectedDirectoryLabel = exportTargetUri?.let { uri ->
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
} ?: "App storage (default)",
selectedDirectoryLabel =
exportTargetUri?.let { uri ->
DocumentFile.fromTreeUri(context, uri)?.name ?: uri.lastPathSegment ?: "(selected)"
} ?: "App storage (default)",
onSelectDirectory = { folderPicker.launch(null) },
onDismiss = { listToExport = null; exportTargetUri = null },
onDismiss = {
listToExport = null
exportTargetUri = null
},
onConfirmExport = { format ->
if (format == "CSV") {
coroutineScope.launch {
val result = viewModel.exportListToCsv(list.id, list.name, exportTargetUri)
result.onSuccess { path ->
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
}.onFailure {
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
}
result
.onSuccess { path ->
Toast.makeText(context, context.getString(R.string.export_success, path), Toast.LENGTH_LONG).show()
}.onFailure {
Toast.makeText(context, context.getString(R.string.export_error), Toast.LENGTH_LONG).show()
}
listToExport = null
exportTargetUri = null
}
@@ -500,7 +507,6 @@ fun CreateListDialog(
}
},
)
}
@Composable
@@ -612,7 +618,10 @@ private fun ExportListDialog(
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.format_csv)) },
onClick = { selectedFormat = "CSV"; expanded = false },
onClick = {
selectedFormat = "CSV"
expanded = false
},
)
}
}
@@ -1,6 +1,8 @@
package com.collabtable.app.ui.screens
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
@@ -19,12 +21,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.io.File
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.UUID
import java.util.Locale
import java.util.UUID
class ListsViewModel(
private val database: CollabTableDatabase,
@@ -239,34 +239,41 @@ class ListsViewModel(
targetTreeUri: Uri? = null,
): Result<String> {
return try {
// Load fields (ordered) and items with values
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
// Load fields (ordered) and items with values
val fields: List<Field> = database.fieldDao().getFieldsForList(listId).first()
val items: List<ItemWithValues> = database.itemDao().getItemsWithValuesForList(listId).first()
// Build header
val headers = fields.map { escapeCsv(it.name) }
val fieldIdOrder = fields.map { it.id }
// Build header
val headers = fields.map { escapeCsv(it.name) }
val fieldIdOrder = fields.map { it.id }
val rows = items.map { iwv ->
val valueMap = iwv.values.associateBy { it.fieldId }
fieldIdOrder.joinToString(",") { fid ->
val raw = valueMap[fid]?.value ?: ""
escapeCsv(raw)
}
}
val rows =
items.map { iwv ->
val valueMap = iwv.values.associateBy { it.fieldId }
fieldIdOrder.joinToString(",") { fid ->
val raw = valueMap[fid]?.value ?: ""
escapeCsv(raw)
}
}
val csv = buildString {
append(headers.joinToString(","))
append('\n')
rows.forEachIndexed { idx, row ->
append(row)
if (idx != rows.lastIndex) append('\n')
}
}
val csv =
buildString {
append(headers.joinToString(","))
append('\n')
rows.forEachIndexed { idx, row ->
append(row)
if (idx != rows.lastIndex) append('\n')
}
}
val safeBase = listName.lowercase(Locale.US).replace("[^a-z0-9]+".toRegex(), "_").trim('_').ifBlank { "table" }
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "${safeBase}_${ts}.csv"
val safeBase =
listName
.lowercase(Locale.US)
.replace("[^a-z0-9]+".toRegex(), "_")
.trim('_')
.ifBlank { "table" }
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "${safeBase}_$ts.csv"
if (targetTreeUri != null) {
val docTree = DocumentFile.fromTreeUri(context, targetTreeUri)
if (docTree == null || !docTree.isDirectory || !docTree.canWrite()) {
@@ -453,7 +453,7 @@ fun SettingsScreen(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
Text(
text = "View application logs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -538,7 +538,8 @@ fun SettingsScreen(
onDismissRequest = { showLeaveDialog = false },
title = { Text(stringResource(R.string.leave_server_dialog_title)) },
text = {
Text(stringResource(R.string.leave_server_dialog_body),
Text(
stringResource(R.string.leave_server_dialog_body),
)
},
confirmButton = {
+1 -1
View File
@@ -26,7 +26,7 @@ function sleep(ms: number) {
async function initializeDatabaseWithRetry() {
let attempt = 0;
while (true) {
for (;;) {
attempt += 1;
try {
+6 -2
View File
@@ -57,7 +57,9 @@ router.post('/values', async (req: Request, res: Response) => {
if (listId) {
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
}
} catch {}
} catch (notificationError) {
void notificationError;
}
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
@@ -101,7 +103,9 @@ router.delete('/:id', async (req: Request, res: Response) => {
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 {}
} catch (notificationError) {
void notificationError;
}
res.json({ message: 'Item deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete item' });
+12 -4
View File
@@ -167,7 +167,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try {
const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
} catch {}
} catch (notificationError) {
void notificationError;
}
// 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)
@@ -201,7 +203,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try {
const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
} catch {}
} catch (notificationError) {
void notificationError;
}
if (field.isDeleted) {
// Remove item_values for this field
try {
@@ -239,7 +243,9 @@ router.post('/sync', async (req: Request, res: Response) => {
try {
const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
} catch {}
} catch (notificationError) {
void notificationError;
}
// If an item was deleted, remove its values (no tombstone support for values)
if (item.isDeleted) {
try {
@@ -324,7 +330,9 @@ router.post('/sync', async (req: Request, res: Response) => {
if (lId) {
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt);
}
} catch {}
} catch (notificationError) {
void notificationError;
}
} catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') {