Compare commits

7 Commits
Author SHA1 Message Date
Gabriel20xxandGitHub e2592508cb Merge pull request #2 from gabriel20xx/copilot/fix-startup-issue-retry-db-connection
Add retry mechanism for database connection on startup
2026-05-19 13:23:36 +02:00
09ae5309d4 Clamp and validate DB_CONNECT_RETRIES and DB_CONNECT_RETRY_DELAY_MS env vars
Agent-Logs-Url: https://github.com/gabriel20xx/CollabTable/sessions/3d38d5f2-ce12-45a3-bcfe-9fb96f3581ce

Co-authored-by: gabriel20xx <21219769+gabriel20xx@users.noreply.github.com>
2026-05-19 11:23:06 +00:00
Gabriel20xxandGitHub dbab7b1934 Merge pull request #3 from gabriel20xx/copilot/create-github-workflow-file
Add GitHub Actions workflow to build and publish Docker image to Docker Hub
2026-05-19 13:22:40 +02:00
a896ae8209 Skip Docker Hub login step on pull_request events
Agent-Logs-Url: https://github.com/gabriel20xx/CollabTable/sessions/aef8ec67-fa60-4789-b968-81db2b5c793c

Co-authored-by: gabriel20xx <21219769+gabriel20xx@users.noreply.github.com>
2026-05-19 11:22:16 +00:00
94382573d0 Add Docker build and publish GitHub Actions workflow
Agent-Logs-Url: https://github.com/gabriel20xx/CollabTable/sessions/e8ce937b-f84e-41c0-a60a-155c2f512a61

Co-authored-by: gabriel20xx <21219769+gabriel20xx@users.noreply.github.com>
2026-05-19 11:16:36 +00:00
76c4a5ef96 Use exponential backoff and tighten connection error detection
Agent-Logs-Url: https://github.com/gabriel20xx/CollabTable/sessions/2bd9068b-15e3-43bb-81b9-94b4fb93bfdc

Co-authored-by: gabriel20xx <21219769+gabriel20xx@users.noreply.github.com>
2026-05-19 11:16:09 +00:00
c82b8aebca Add retry mechanism for database connection on startup
Agent-Logs-Url: https://github.com/gabriel20xx/CollabTable/sessions/2bd9068b-15e3-43bb-81b9-94b4fb93bfdc

Co-authored-by: gabriel20xx <21219769+gabriel20xx@users.noreply.github.com>
2026-05-19 11:15:05 +00:00
31 changed files with 374 additions and 665 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./CollabTableServer
file: ./CollabTableServer/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: |
gabriel20xx/collabtable:latest
gabriel20xx/collabtable:${{ github.sha }}
-73
View File
@@ -1,73 +0,0 @@
name: Server CI
on:
push:
branches: [ '**' ]
paths:
- 'CollabTableServer/**'
- '.github/workflows/server-ci.yml'
pull_request:
branches: [ '**' ]
paths:
- 'CollabTableServer/**'
- '.github/workflows/server-ci.yml'
workflow_dispatch:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: CollabTableServer
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: CollabTableServer/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
build-and-push:
name: Build & Push Docker Image
needs: lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./CollabTableServer
push: ${{ github.event_name == 'push' }}
tags: |
gabriel20xx/CollabTableServer:latest
gabriel20xx/CollabTableServer:${{ github.sha }}
platforms: linux/amd64,linux/arm64
@@ -53,7 +53,6 @@ class CollabTableApplication : Application() {
NotificationHelper.clearListEventNotifications(this@CollabTableApplication)
NotificationPoller.start(this@CollabTableApplication)
}
override fun onStop(owner: LifecycleOwner) {
NotificationPoller.stop()
}
@@ -110,8 +110,7 @@ object ApiClient {
val ensuredApi = if (rawUrl.contains("/api")) rawUrl else rawUrl.trimEnd('/') + "/api/"
ensureTrailingSlash(ensuredApi)
}
} catch (error: Exception) {
Logger.w("HTTP", "Failed to normalize API URL '$rawUrl': ${error.message}")
} catch (e: Exception) {
ensureTrailingSlash(rawUrl)
}
}
@@ -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.GET
import retrofit2.http.POST
import retrofit2.http.GET
import retrofit2.http.Query
data class SyncRequest(
@@ -47,13 +47,6 @@ interface FieldDao {
@Query("SELECT * FROM fields WHERE updatedAt >= :since")
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,
)
// Reorder fields in a single transaction for clarity and consistency
@Transaction
suspend fun reorderFieldsInTransaction(
@@ -61,7 +54,12 @@ interface FieldDao {
timestamp: Long,
) {
reorderedFields.forEachIndexed { index, field ->
updateFieldOrder(field.id, index, timestamp)
updateField(
field.copy(
order = index,
updatedAt = timestamp,
),
)
}
}
}
@@ -16,8 +16,7 @@ enum class FieldType {
PERCENTAGE,
// Selection types
SELECT,
MULTI_SELECT,
DROPDOWN,
AUTOCOMPLETE,
CHECKBOX,
SWITCH,
@@ -73,23 +72,22 @@ data class Field(
) {
fun getType(): FieldType {
val raw = fieldType.ifBlank { "TEXT" }.uppercase()
val exact = runCatching { FieldType.valueOf(raw) }.getOrNull()
if (exact != null) {
return exact
}
// Handle legacy/synonym field types
return when (raw) {
"DROPDOWN" -> FieldType.SELECT
"STRING" -> FieldType.TEXT
"PRICE" -> FieldType.CURRENCY
"AMOUNT" -> FieldType.NUMBER
"SIZE" -> FieldType.TEXT
else -> FieldType.TEXT
return try {
FieldType.valueOf(raw)
} catch (e: Exception) {
// Handle legacy/synonym field types
when (raw) {
"STRING" -> FieldType.TEXT
"PRICE" -> FieldType.CURRENCY
"AMOUNT" -> FieldType.NUMBER
"SIZE" -> FieldType.TEXT
else -> FieldType.TEXT
}
}
}
fun getSelectOptions(): List<String> =
if ((getType() == FieldType.SELECT || getType() == FieldType.MULTI_SELECT) && fieldOptions.isNotBlank()) {
fun getDropdownOptions(): List<String> =
if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fieldOptions.split("|")
} else {
emptyList()
@@ -16,8 +16,8 @@ class PreferencesManager(
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _isFirstRunFlow = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRunFlow.asStateFlow()
private val _isFirstRun = MutableStateFlow(isFirstRun())
val isFirstRunFlow: StateFlow<Boolean> = _isFirstRun.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()
_isFirstRunFlow.value = isFirstRun
_isFirstRun.value = isFirstRun
}
fun getServerPassword(): String? = prefs.getString(KEY_SERVER_PASSWORD, null)
@@ -194,7 +194,7 @@ class PreferencesManager(
fun getColumnWidths(listId: String): Map<String, Float> {
val key = COLUMN_WIDTHS_PREFIX + listId
val raw = prefs.getString(key, null) ?: return emptyMap()
return runCatching {
return try {
val json = JSONObject(raw)
val map = mutableMapOf<String, Float>()
val it = json.keys()
@@ -206,7 +206,9 @@ class PreferencesManager(
}
}
map
}.getOrDefault(emptyMap())
} catch (e: Exception) {
emptyMap()
}
}
fun setColumnWidths(
@@ -227,7 +229,7 @@ class PreferencesManager(
fun getColumnAlignments(listId: String): Map<String, String> {
val key = COLUMN_ALIGN_PREFIX + listId
val raw = prefs.getString(key, null) ?: return emptyMap()
return runCatching {
return try {
val json = JSONObject(raw)
val map = mutableMapOf<String, String>()
val it = json.keys()
@@ -243,7 +245,9 @@ class PreferencesManager(
map[fieldId] = normalized
}
map
}.getOrDefault(emptyMap())
} catch (e: Exception) {
emptyMap()
}
}
fun setColumnAlignments(
@@ -268,10 +272,7 @@ 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
}
@@ -312,14 +312,14 @@ class SyncRepository(
private fun isNetworkAvailable(): Boolean {
val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork
val caps = network?.let { cm.getNetworkCapabilities(it) }
val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val isValidated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasTransport =
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true ||
caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
return hasInternet && isValidated && hasTransport
}
}
@@ -1,12 +1,8 @@
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 {
@@ -38,15 +34,6 @@ 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,6 +2,7 @@ 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
@@ -22,48 +23,44 @@ 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)
}
}
// 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)
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 interval = prefs.syncPollIntervalMs.value
delay(interval)
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() {
@@ -79,24 +76,20 @@ 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) {
"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)
}
else ->
when (ev.eventType.lowercase()) {
"listcontentupdated" ->
if (prefs.notifyListContentUpdated.value) {
NotificationHelper.showListContentUpdated(context, ev.listId ?: "", listName)
}
}
}
}
}
}
@@ -53,7 +53,7 @@ private fun isEmulator(): Boolean {
private fun toHealthUrl(rawApiUrl: String): String {
// Ensure we hit the unauthenticated /health endpoint and remap localhost on emulator
return runCatching {
return try {
val uri = Uri.parse(rawApiUrl)
val host = uri.host?.lowercase()
val needsRemap = isEmulator() && (host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal")
@@ -63,7 +63,7 @@ private fun toHealthUrl(rawApiUrl: String): String {
val path = (uri.encodedPath ?: "/").trimEnd('/')
val healthPath = if (path.endsWith("/api") || path.endsWith("/api/")) "/health" else "/health"
base + healthPath
}.getOrElse {
} catch (e: Exception) {
rawApiUrl.replace("/api/", "/health").replace("/api", "/health")
}
}
@@ -101,7 +101,7 @@ fun ConnectionStatusAction(
ok = false
}
resp.close()
} catch (ignored: Exception) {
} catch (e: Exception) {
ok = false
}
delay(intervalMs)
@@ -1,9 +1,4 @@
@file:Suppress(
"ktlint:standard:no-wildcard-imports",
"SwallowedException",
"UnusedParameter",
"UnusedPrivateMember",
)
@file:Suppress("ktlint:standard:no-wildcard-imports")
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package com.collabtable.app.ui.screens
@@ -56,6 +51,7 @@ 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
@@ -230,7 +226,6 @@ 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,
@@ -240,7 +235,7 @@ fun ListDetailScreen(
filterField,
filterValue,
) {
val transformedItems =
value =
withContext(Dispatchers.Default) {
transformItems(
items = stableItems,
@@ -251,7 +246,6 @@ fun ListDetailScreen(
filterValue = filterValue,
)
}
value = transformedItems
}
val processedItems = transformed.processed
@@ -556,19 +550,17 @@ 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,
)
}
@@ -647,8 +639,8 @@ fun ListDetailScreen(
onAddField = { name, fieldType, fieldOptions ->
viewModel.addField(name, fieldType, fieldOptions)
},
onUpdateField = { fieldId, name, fieldType, fieldOptions, alignment ->
viewModel.updateField(fieldId, name, fieldType, fieldOptions, alignment)
onUpdateField = { fieldId, name, fieldType, fieldOptions ->
viewModel.updateField(fieldId, name, fieldType, fieldOptions)
},
onUpdateAlignment = { fieldId, alignment ->
viewModel.updateFieldAlignment(fieldId, alignment)
@@ -812,8 +804,7 @@ private fun getDisplayTextForMeasure(
com.collabtable.app.data.model.FieldType.TEXT,
com.collabtable.app.data.model.FieldType.MULTILINE_TEXT,
com.collabtable.app.data.model.FieldType.NUMBER,
com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
com.collabtable.app.data.model.FieldType.DROPDOWN,
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE,
com.collabtable.app.data.model.FieldType.DURATION,
com.collabtable.app.data.model.FieldType.LOCATION,
@@ -857,8 +848,7 @@ private fun fieldTypeToLabel(type: String): String =
"NUMBER" -> "Number"
"CURRENCY", "PRICE" -> "Currency"
"PERCENTAGE" -> "Percentage"
"SELECT", "Select" -> "Select"
"MULTI_SELECT" -> "Multi-select"
"DROPDOWN" -> "Dropdown"
"AUTOCOMPLETE" -> "Autocomplete"
"CHECKBOX" -> "Checkbox"
"SWITCH" -> "Switch"
@@ -1054,7 +1044,8 @@ 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
@@ -1111,9 +1102,7 @@ fun ItemRow(
)
}
com.collabtable.app.data.model.FieldType.SELECT,
com.collabtable.app.data.model.FieldType.MULTI_SELECT,
-> {
com.collabtable.app.data.model.FieldType.DROPDOWN -> {
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
@@ -1587,16 +1576,9 @@ fun AddFieldDialog(
// Selection types
DropdownMenuItem(
text = { Text("Select") },
text = { Text("Dropdown") },
onClick = {
selectedFieldType = "SELECT"
expanded = false
},
)
DropdownMenuItem(
text = { Text("Multi-select") },
onClick = {
selectedFieldType = "MULTI_SELECT"
selectedFieldType = "DROPDOWN"
expanded = false
},
)
@@ -1743,14 +1725,14 @@ fun AddFieldDialog(
}
// Dropdown-specific options
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
if (selectedFieldType == "DROPDOWN") {
OutlinedTextField(
value = dropdownOptions,
onValueChange = { dropdownOptions = it },
label = { Text("Options (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Option 1, Option 2, Option 3") },
supportingText = { Text("Enter select choices separated by commas") },
supportingText = { Text("Enter dropdown choices separated by commas") },
)
}
@@ -1788,7 +1770,7 @@ fun AddFieldDialog(
val options =
when (normalizedType) {
"CURRENCY" -> currency.trim()
"SELECT", "MULTI_SELECT" ->
"DROPDOWN" ->
dropdownOptions
.split(",")
.map { it.trim() }
@@ -1808,7 +1790,7 @@ fun AddFieldDialog(
},
enabled =
fieldName.isNotBlank() &&
(selectedFieldType.uppercase() !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
(selectedFieldType.uppercase() !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) {
Text(stringResource(R.string.add))
}
@@ -1830,7 +1812,7 @@ fun EditFieldDialog(
) {
var name by remember { mutableStateOf(field.name) }
var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) }
var dropdownOptions by remember { mutableStateOf(field.getSelectOptions().joinToString(", ")) }
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) }
var currency by remember { mutableStateOf(field.getCurrency()) }
var expanded by remember { mutableStateOf(false) }
// Alignment state persisted per list/field in PreferencesManager
@@ -1920,16 +1902,9 @@ fun EditFieldDialog(
// Selection types
DropdownMenuItem(
text = { Text("Select") },
text = { Text("Dropdown") },
onClick = {
selectedFieldType = "SELECT"
expanded = false
},
)
DropdownMenuItem(
text = { Text("Multi-select") },
onClick = {
selectedFieldType = "MULTI_SELECT"
selectedFieldType = "DROPDOWN"
expanded = false
},
)
@@ -2076,14 +2051,14 @@ fun EditFieldDialog(
}
// Dropdown-specific options
if (selectedFieldType == "SELECT" || selectedFieldType == "MULTI_SELECT") {
if (selectedFieldType == "DROPDOWN") {
OutlinedTextField(
value = dropdownOptions,
onValueChange = { dropdownOptions = it },
label = { Text("Options (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Option 1, Option 2, Option 3") },
supportingText = { Text("Enter select choices separated by commas") },
supportingText = { Text("Enter dropdown choices separated by commas") },
)
}
@@ -2138,7 +2113,7 @@ fun EditFieldDialog(
val options =
when (normalizedType) {
"CURRENCY", "PRICE" -> currency.trim()
"SELECT", "MULTI_SELECT" ->
"DROPDOWN" ->
dropdownOptions
.split(",")
.map { it.trim() }
@@ -2164,7 +2139,7 @@ fun EditFieldDialog(
}
prefs.setColumnAlignments(field.listId, current)
},
enabled = name.isNotBlank() && (selectedFieldType !in listOf("SELECT", "MULTI_SELECT", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) {
Text("Update")
}
@@ -2331,13 +2306,13 @@ fun FieldInput(
}
}
com.collabtable.app.data.model.FieldType.SELECT -> {
val options = field.getSelectOptions()
var expanded by remember { mutableStateOf(false) }
com.collabtable.app.data.model.FieldType.DROPDOWN -> {
val options = field.getDropdownOptions()
var dropdownExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = !dropdownExpanded },
) {
OutlinedTextField(
value = value,
@@ -2345,7 +2320,7 @@ fun FieldInput(
readOnly = true,
label = { Text(field.name) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier =
Modifier
@@ -2354,71 +2329,15 @@ fun FieldInput(
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onValueChange(option)
expanded = false
},
)
}
}
}
}
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()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = { Text(field.name) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier =
Modifier
.menuAnchor()
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
val isSelected = selectedOptions.contains(option)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(modifier = Modifier.width(8.dp))
Text(option)
}
},
onClick = {
val newSelected =
if (isSelected) {
selectedOptions - option
} else {
selectedOptions + option
}
val orderedNewSelected = options.filter { it in newSelected }
onValueChange(orderedNewSelected.joinToString(", "))
dropdownExpanded = false
},
)
}
@@ -2883,7 +2802,7 @@ fun ManageColumnsDialog(
fields: List<Field>,
onDismiss: () -> Unit,
onAddField: (String, String, String) -> Unit,
onUpdateField: (String, String, String, String, String) -> Unit,
onUpdateField: (String, String, String, String) -> Unit,
onUpdateAlignment: (String, String) -> Unit,
onDeleteField: (String) -> Unit,
onReorderFields: (List<Field>) -> Unit,
@@ -3073,7 +2992,8 @@ fun ManageColumnsDialog(
field = field,
onDismiss = { fieldToEdit = null },
onUpdate = { name, fieldType, fieldOptions, selectedAlignment ->
onUpdateField(field.id, name, fieldType, fieldOptions, selectedAlignment)
onUpdateField(field.id, name, fieldType, fieldOptions)
onUpdateAlignment(field.id, selectedAlignment)
fieldToEdit = null
},
)
@@ -3150,10 +3070,8 @@ fun ColumnItem(
// Selection types
com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox"
com.collabtable.app.data.model.FieldType.SWITCH -> "Switch"
com.collabtable.app.data.model.FieldType.SELECT ->
"Select (${field.getSelectOptions().size} options)"
com.collabtable.app.data.model.FieldType.MULTI_SELECT ->
"Multi-select (${field.getSelectOptions().size} options)"
com.collabtable.app.data.model.FieldType.DROPDOWN ->
"Dropdown (${field.getDropdownOptions().size} options)"
com.collabtable.app.data.model.FieldType.AUTOCOMPLETE ->
"Autocomplete (${field.getAutocompleteOptions().size} options)"
@@ -1,6 +1,8 @@
package com.collabtable.app.ui.screens
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction
@@ -121,6 +123,11 @@ class ListDetailViewModel(
syncRepository.performSync()
}
private fun isInForeground(): Boolean {
val state = ProcessLifecycleOwner.get().lifecycle.currentState
return state.isAtLeast(Lifecycle.State.STARTED)
}
// 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 */ }
@@ -217,25 +224,17 @@ class ListDetailViewModel(
name: String,
fieldType: String,
fieldOptions: String,
alignment: String,
) {
viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId)
if (field != null) {
val ts = System.currentTimeMillis()
val normalizedAlign =
when (alignment.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
database.withTransaction {
database.fieldDao().updateField(
field.copy(
name = name,
fieldType = fieldType,
fieldOptions = fieldOptions,
alignment = normalizedAlign,
updatedAt = ts,
),
)
@@ -3,9 +3,7 @@
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
@@ -31,6 +29,9 @@ 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
@@ -59,7 +60,6 @@ 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
@@ -72,7 +72,6 @@ import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@Suppress("UnusedParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
@@ -277,7 +276,7 @@ fun ListsScreen(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
itemsIndexed(working, key = { _, table -> table.id }) { _, list ->
itemsIndexed(working, key = { _, it -> it.id }) { _, list ->
ReorderableItem(reorderState, key = list.id) { _ ->
Box(modifier = Modifier.animateItemPlacement()) {
ListItem(
@@ -350,39 +349,32 @@ 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
}
@@ -508,6 +500,7 @@ fun CreateListDialog(
}
},
)
}
@Composable
@@ -619,10 +612,7 @@ 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,8 +1,6 @@
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
@@ -21,10 +19,12 @@ 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.Locale
import java.util.UUID
import java.util.Locale
class ListsViewModel(
private val database: CollabTableDatabase,
@@ -233,48 +233,40 @@ class ListsViewModel(
* stored in the app's external files directory. Returns a Result containing
* the absolute file path on success.
*/
@Suppress("ReturnCount")
suspend fun exportListToCsv(
listId: String,
listName: String,
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()) {
@@ -1,5 +1,3 @@
@file:Suppress("SwallowedException")
package com.collabtable.app.ui.screens
import android.net.Uri
@@ -64,7 +64,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Suppress("UnusedParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
@@ -454,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,
@@ -539,8 +538,7 @@ 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 = {
@@ -84,13 +84,8 @@ object Logger {
): Boolean {
val now = System.currentTimeMillis()
val last = _logs.value.lastOrNull()
val isDuplicateMessage =
last?.let { previous ->
previous.level == level && previous.tag == tag && previous.message == message
} ?: false
val withinDedupeWindow = last != null && (now - last.timestamp) <= DEDUPE_WINDOW_MS
// Suppress exact duplicate consecutive logs within a short window
if (isDuplicateMessage && withinDedupeWindow) {
if (last != null && last.level == level && last.tag == tag && last.message == message && (now - last.timestamp) <= DEDUPE_WINDOW_MS) {
return false
}
val entry = LogEntry(timestamp = now, level = level, tag = tag, message = message)
@@ -7,7 +7,6 @@ import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.notifications.NotificationHelper
import com.collabtable.app.utils.Logger
class ListChangeNotificationWorker(
appContext: Context,
@@ -29,8 +28,7 @@ class ListChangeNotificationWorker(
// Update checkpoint regardless to avoid duplicate spam
prefs.setLastListNotifyCheckTimestamp(now)
Result.success()
} catch (error: Exception) {
Logger.w("ListNotifyWorker", "Background notification polling failed: ${error.message}")
} catch (e: Exception) {
Result.retry()
}
}
-16
View File
@@ -33,19 +33,3 @@ PGPORT=5432
PGUSER=postgres
PGPASSWORD=
PGDATABASE=collabtable
#################################################################
# Startup resilience and sync/auth tuning
#################################################################
# DB init retry interval in milliseconds (default: 5000)
DB_INIT_RETRY_INTERVAL_MS=5000
# Max DB init retry attempts. 0 means retry forever (default: 0)
DB_INIT_RETRY_MAX_ATTEMPTS=0
# Suppress repetitive auth warning logs for the same path/method (default: 30000)
AUTH_LOG_THROTTLE_MS=30000
# Allow at most this much client clock skew into sync timestamps (default: 0)
# Keep this at 0 to prevent future-dated rows from being re-sent in hot loops.
SYNC_MAX_FUTURE_SKEW_MS=0
-4
View File
@@ -230,10 +230,6 @@ docker-compose up -d --build
- `DB_PATH` - Path to SQLite database file (default: ./data/collabtable.db for local, /data/collabtable.db in Docker)
- `DATABASE_URL` - PostgreSQL connection URL (optional alternative to PG* variables)
- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` - PostgreSQL connection parameters
- `DB_INIT_RETRY_INTERVAL_MS` - Delay between DB init retries in ms (default: `5000`)
- `DB_INIT_RETRY_MAX_ATTEMPTS` - Max DB init retries; `0` retries forever (default: `0`)
- `AUTH_LOG_THROTTLE_MS` - Minimum interval before repeating the same auth warning log (default: `30000`)
- `SYNC_MAX_FUTURE_SKEW_MS` - Allowed future timestamp skew from clients during sync (default: `0`)
## Data Persistence
Binary file not shown.
-5
View File
@@ -14,11 +14,6 @@ services:
- DB_CLIENT=sqlite
# SQLite path (used when DB_CLIENT=sqlite)
- DB_PATH=/data/collabtable.db
# Retry DB initialization instead of crashing immediately on startup races
- DB_INIT_RETRY_INTERVAL_MS=5000
- DB_INIT_RETRY_MAX_ATTEMPTS=0
# Clamp future client timestamps during sync to avoid resend loops
- SYNC_MAX_FUTURE_SKEW_MS=0
# PostgreSQL (used when DB_CLIENT=postgres) - choose either DATABASE_URL or the PG* vars
# - DATABASE_URL=postgres://user:password@postgres:5432/collabtable
# - PGHOST=postgres
+4 -4
View File
@@ -27,7 +27,7 @@
"@types/node": "^20.10.6",
"@types/pg": "^8.10.2",
"ts-node-dev": "^2.0.0",
"typescript": "5.5.4"
"typescript": "^5.3.3"
}
},
"node_modules/@cspotcode/source-map-support": {
@@ -3607,9 +3607,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+1 -1
View File
@@ -32,7 +32,7 @@
"@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11",
"@types/pg": "^8.10.2",
"typescript": "5.5.4",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}
+27 -1
View File
@@ -296,7 +296,33 @@ export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType ===
: new SqliteAdapter();
export async function initializeDatabase() {
await dbAdapter.initialize();
const parsedRetries = parseInt(process.env.DB_CONNECT_RETRIES || '10', 10);
const maxRetries = Number.isNaN(parsedRetries) || parsedRetries < 1 ? 10 : parsedRetries;
const parsedDelay = parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS || '3000', 10);
const retryDelayMs = Number.isNaN(parsedDelay) || parsedDelay < 0 ? 3000 : parsedDelay;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await dbAdapter.initialize();
return;
} catch (err: any) {
const isConnectionError =
err?.code === 'ECONNREFUSED' ||
err?.code === 'ENOTFOUND' ||
err?.code === 'ETIMEDOUT';
if (!isConnectionError || attempt === maxRetries) {
throw err;
}
const delay = retryDelayMs * Math.pow(2, attempt - 1);
console.warn(
`Database connection attempt ${attempt}/${maxRetries} failed (${err.code || err.message}). Retrying in ${delay}ms...`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
export type { DBAdapter };
+1 -34
View File
@@ -16,39 +16,6 @@ dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
const DB_INIT_RETRY_INTERVAL_MS = Math.max(250, Number(process.env.DB_INIT_RETRY_INTERVAL_MS || 5000));
const DB_INIT_RETRY_MAX_ATTEMPTS = Math.max(0, Number(process.env.DB_INIT_RETRY_MAX_ATTEMPTS || 0));
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function initializeDatabaseWithRetry() {
let attempt = 0;
for (;;) {
attempt += 1;
try {
await initializeDatabase();
if (attempt > 1) {
console.log(`[DB] Connected after ${attempt} attempt(s)`);
}
return;
} catch (error) {
const reachedLimit = DB_INIT_RETRY_MAX_ATTEMPTS > 0 && attempt >= DB_INIT_RETRY_MAX_ATTEMPTS;
console.error(`[DB] Initialization attempt ${attempt} failed`, error);
if (reachedLimit) {
throw error;
}
console.log(`[DB] Retrying in ${DB_INIT_RETRY_INTERVAL_MS}ms...`);
await sleep(DB_INIT_RETRY_INTERVAL_MS);
}
}
}
// Middleware
app.use(cors());
@@ -77,7 +44,7 @@ app.use('/', webRoutes);
// Initialize database and start server
(async () => {
try {
await initializeDatabaseWithRetry();
await initializeDatabase();
console.log('Database synchronized successfully');
console.log('Database models loaded:');
console.log('- Lists');
+20 -52
View File
@@ -1,33 +1,6 @@
import { Request, Response, NextFunction } from 'express';
let warningLogged = false;
const AUTH_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.AUTH_LOG_THROTTLE_MS || 30000));
const lastAuthLogAt = new Map<string, number>();
const commonProbePathPatterns = [
/^\/\./,
/client_secret\.json$/i,
/phpinfo/i,
/\.git/i,
/wp-admin/i,
/wp-login/i,
/id_rsa/i,
/config(\.|$)/i
];
function isCommonProbePath(pathname: string) {
return commonProbePathPatterns.some(pattern => pattern.test(pathname));
}
function shouldLogAuthWarning(kind: string, req: Request) {
const key = `${kind}:${req.method}:${req.path}`;
const now = Date.now();
const lastSeen = lastAuthLogAt.get(key) || 0;
if (now - lastSeen < AUTH_LOG_THROTTLE_MS) {
return false;
}
lastAuthLogAt.set(key, now);
return true;
}
export const authenticatePassword = (req: Request, res: Response, next: NextFunction) => {
const serverPassword = process.env.SERVER_PASSWORD;
@@ -45,13 +18,12 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
const authHeader = req.headers.authorization;
if (!authHeader) {
if (!isCommonProbePath(req.path) && shouldLogAuthWarning('missingHeader', req)) {
console.warn('[AUTH] Missing Authorization header', {
path: req.path,
method: req.method,
ip: req.ip,
});
}
// Log diagnostic info to help trace intermittent 401s
console.warn('[AUTH] Missing Authorization header', {
path: req.path,
method: req.method,
ip: req.ip,
});
return res.status(401).json({
error: 'Unauthorized',
message: 'No authorization header provided'
@@ -61,15 +33,13 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
// Parse the authorization header
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
if (shouldLogAuthWarning('invalidHeaderFormat', req)) {
console.warn('[AUTH] Invalid authorization header format', {
path: req.path,
method: req.method,
ip: req.ip,
// Do not log full header to avoid leaking secrets; include prefix only
headerPreview: authHeader.substring(0, Math.min(authHeader.length, 20)) + '...'
});
}
console.warn('[AUTH] Invalid authorization header format', {
path: req.path,
method: req.method,
ip: req.ip,
// Do not log full header to avoid leaking secrets; include prefix only
headerPreview: authHeader.substring(0, Math.min(authHeader.length, 20)) + '...'
});
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid authorization header format. Expected: Bearer <password>'
@@ -81,15 +51,13 @@ export const authenticatePassword = (req: Request, res: Response, next: NextFunc
// Validate password
if (providedPassword !== serverPassword) {
// Avoid logging the full password; log only length and a small preview
if (shouldLogAuthWarning('invalidPassword', req)) {
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}..(${providedPassword.length})` : 'null';
console.warn('[AUTH] Invalid password', {
path: req.path,
method: req.method,
ip: req.ip,
providedPreview: safePreview,
});
}
const safePreview = providedPassword ? `${providedPassword.substring(0, 2)}…(${providedPassword.length})` : 'null';
console.warn('[AUTH] Invalid password', {
path: req.path,
method: req.method,
ip: req.ip,
providedPreview: safePreview,
});
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid password'
+2 -6
View File
@@ -57,9 +57,7 @@ router.post('/values', async (req: Request, res: Response) => {
if (listId) {
await enqueueNotification(dbAdapter, (req as any).deviceId, 'listContentUpdated', 'value', id, listId, Date.now());
}
} catch (notificationError) {
void notificationError;
}
} catch {}
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
@@ -103,9 +101,7 @@ 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 (notificationError) {
void notificationError;
}
} catch {}
res.json({ message: 'Item deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete item' });
+39 -89
View File
@@ -12,36 +12,6 @@ interface SyncRequest {
itemValues: any[];
}
const SYNC_MAX_FUTURE_SKEW_MS = Math.max(0, Number(process.env.SYNC_MAX_FUTURE_SKEW_MS || 0));
function normalizeTimestamp(raw: any, fallback: number, nowMs: number) {
const numeric = Number(raw);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
let ts = Math.trunc(numeric);
if (ts < 1e12) {
ts *= 1000;
}
const maxAllowed = nowMs + SYNC_MAX_FUTURE_SKEW_MS;
if (ts > maxAllowed) {
return maxAllowed;
}
return ts;
}
function normalizeEntityTimestamps(createdAtRaw: any, updatedAtRaw: any, nowMs: number) {
const createdAt = normalizeTimestamp(createdAtRaw, nowMs, nowMs);
const updatedAt = normalizeTimestamp(updatedAtRaw, createdAt, nowMs);
return {
createdAt,
updatedAt: Math.max(createdAt, updatedAt)
};
}
// Stats tracking
let syncStats = {
totalSyncs: 0,
@@ -85,7 +55,7 @@ setInterval(() => {
lastReset: Date.now()
};
}
}, 60000).unref?.(); // Changed from 30s to 60s
}, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization)
// We'll perform upserts with positional params for cross-DB portability
@@ -96,23 +66,15 @@ 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 = []
}: Partial<SyncRequest> = req.body || {};
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
const deviceId = (req as any).deviceId as string | undefined;
const nowMs = Date.now();
const safeLastSyncTimestamp = normalizeTimestamp(lastSyncTimestamp, 0, nowMs);
// Track stats
syncStats.totalSyncs++;
const incomingLists = lists.length;
const incomingFields = fields.length;
const incomingItems = items.length;
const incomingValues = itemValues.length;
const incomingLists = lists?.length || 0;
const incomingFields = fields?.length || 0;
const incomingItems = items?.length || 0;
const incomingValues = itemValues?.length || 0;
syncStats.listsReceived += incomingLists;
syncStats.fieldsReceived += incomingFields;
@@ -121,7 +83,7 @@ router.post('/sync', async (req: Request, res: Response) => {
// Only log significant sync events (not empty syncs)
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
const isInitialSync = safeLastSyncTimestamp === 0;
const isInitialSync = lastSyncTimestamp === 0;
if (hasIncomingData) {
const timestamp = new Date().toLocaleTimeString();
@@ -130,7 +92,7 @@ router.post('/sync', async (req: Request, res: Response) => {
if (incomingLists > 0) {
console.log(` [LISTS] ${incomingLists} list(s)`);
lists.forEach(list => {
const action = list.isDeleted ? 'Deleted' : (safeLastSyncTimestamp === 0 ? 'Created' : 'Updated');
const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated');
console.log(` ${action}: "${list.name}"`);
});
}
@@ -155,28 +117,25 @@ router.post('/sync', async (req: Request, res: Response) => {
await dbAdapter.transaction(async (tx) => {
if (lists && lists.length > 0) {
for (const list of lists) {
const listTs = normalizeEntityTimestamps(list.createdAt, list.updatedAt, nowMs);
await tx.execute(UPSERT_LIST, [
list.id,
list.name,
listTs.createdAt,
listTs.updatedAt,
list.createdAt,
list.updatedAt,
list.isDeleted ? 1 : 0
]);
// enqueue list-level notifications
try {
const ev = list.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'list', list.id, list.id, listTs.updatedAt);
} catch (notificationError) {
void notificationError;
}
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)
if (list.isDeleted) {
try {
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [listTs.updatedAt, list.id]);
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [listTs.updatedAt, list.id]);
await tx.execute('UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [list.updatedAt, list.id]);
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [list.id]);
} catch (err) {
console.warn('[SYNC] Cascade delete for list failed:', String(err));
@@ -187,7 +146,6 @@ router.post('/sync', async (req: Request, res: Response) => {
if (fields && fields.length > 0) {
for (const field of fields) {
const fieldTs = normalizeEntityTimestamps(field.createdAt, field.updatedAt, nowMs);
await tx.execute(UPSERT_FIELD, [
field.id,
field.name,
@@ -196,16 +154,14 @@ router.post('/sync', async (req: Request, res: Response) => {
(field.alignment ?? 'start'),
field.listId,
field.order,
fieldTs.createdAt,
fieldTs.updatedAt,
field.createdAt,
field.updatedAt,
field.isDeleted ? 1 : 0
]);
try {
const ev = field.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'field', field.id, field.listId, fieldTs.updatedAt);
} catch (notificationError) {
void notificationError;
}
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 {
@@ -219,7 +175,7 @@ router.post('/sync', async (req: Request, res: Response) => {
const remainingCount = remaining ? (remaining.cnt ?? remaining.CNT ?? remaining.count ?? 0) : 0;
if (remainingCount === 0) {
// Soft delete items in this list and purge their values
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [fieldTs.updatedAt, field.listId]);
await tx.execute('UPDATE items SET isDeleted = 1, updatedAt = ? WHERE listId = ?', [field.updatedAt, field.listId]);
await tx.execute('DELETE FROM item_values WHERE itemId IN (SELECT id FROM items WHERE listId = ?)', [field.listId]);
console.log('[SYNC] Last field deleted; cascaded item+value cleanup for list', field.listId);
}
@@ -232,20 +188,17 @@ router.post('/sync', async (req: Request, res: Response) => {
if (items && items.length > 0) {
for (const item of items) {
const itemTs = normalizeEntityTimestamps(item.createdAt, item.updatedAt, nowMs);
await tx.execute(UPSERT_ITEM, [
item.id,
item.listId,
itemTs.createdAt,
itemTs.updatedAt,
item.createdAt,
item.updatedAt,
item.isDeleted ? 1 : 0
]);
try {
const ev = item.isDeleted ? 'deleted' : (safeLastSyncTimestamp === 0 ? 'created' : 'updated');
await enqueueNotification(tx, deviceId, ev, 'item', item.id, item.listId, itemTs.updatedAt);
} catch (notificationError) {
void notificationError;
}
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 {
@@ -267,9 +220,9 @@ router.post('/sync', async (req: Request, res: Response) => {
// Helper to build IN clause placeholders (?,?,?) matching adapter's ? substitution
const makeIn = (arr: string[]) => arr.map(() => '?').join(', ');
let existingItemIds = new Set<string>();
let existingFieldIds = new Set<string>();
const fieldToList: Record<string, string> = {};
let existingItemIds = new Set<string>();
let existingFieldIds = new Set<string>();
const fieldToList: Record<string, string> = {};
try {
if (distinctItemIds.length > 0) {
const rows = await tx.queryAll(`SELECT id FROM items WHERE id IN (${makeIn(distinctItemIds)})`, distinctItemIds);
@@ -285,7 +238,6 @@ router.post('/sync', async (req: Request, res: Response) => {
}
for (const value of itemValues) {
const valueUpdatedAt = normalizeTimestamp(value.updatedAt, nowMs, nowMs);
const hasItem = existingItemIds.has(value.itemId);
const hasField = existingFieldIds.has(value.fieldId);
if (!hasField) {
@@ -300,7 +252,7 @@ router.post('/sync', async (req: Request, res: Response) => {
orphanValues.push({ id: value.id, itemId: value.itemId, fieldId: value.fieldId, reason: 'missingItemNoList' });
continue;
}
const ts = valueUpdatedAt;
const ts = value.updatedAt ?? Date.now();
try {
await tx.execute(UPSERT_ITEM, [
value.itemId,
@@ -322,17 +274,15 @@ router.post('/sync', async (req: Request, res: Response) => {
value.itemId,
value.fieldId,
value.value,
valueUpdatedAt
value.updatedAt
]);
// Coarse list content update notification
try {
const lId = fieldToList[value.fieldId];
if (lId) {
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, valueUpdatedAt);
await enqueueNotification(tx, deviceId, 'listContentUpdated', 'value', value.id, lId, value.updatedAt);
}
} catch (notificationError) {
void notificationError;
}
} catch {}
} catch (err: any) {
// Catch FK violation just in case race or deletion happened inside same sync
if (err && err.code === '23503') {
@@ -359,17 +309,17 @@ router.post('/sync', async (req: Request, res: Response) => {
// IMPORTANT: Include deleted items so clients can sync deletions
let serverLists, serverFields, serverItems, serverItemValues;
if (safeLastSyncTimestamp === 0) {
if (lastSyncTimestamp === 0) {
// Include deleted rows on initial sync to propagate tombstones
serverLists = await dbAdapter.queryAll('SELECT * FROM lists');
serverFields = await dbAdapter.queryAll('SELECT * FROM fields');
serverItems = await dbAdapter.queryAll('SELECT * FROM items');
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
} else {
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [safeLastSyncTimestamp]);
serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
}
// Normalize output for clients (camelCase keys, ms timestamps, boolean isDeleted)
@@ -433,7 +383,7 @@ router.post('/sync', async (req: Request, res: Response) => {
// Only log when sending significant data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData && !isInitialSync && hasIncomingData) {
if (hasOutgoingData && !isInitialSync) {
console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} else if (isInitialSync && hasOutgoingData) {
console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);