diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt deleted file mode 100644 index 20915c7..0000000 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.collabtable.app.data.api - -import android.content.Context -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.utils.Logger -import com.google.gson.Gson -import com.google.gson.JsonObject -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import okhttp3.logging.HttpLoggingInterceptor -import java.util.UUID -import java.util.concurrent.TimeUnit - -private data class MessageEnvelope( - val id: String? = null, - val type: String, - val payload: Any? = null, -) - -object WebSocketSyncClient { - private val gson = Gson() - - private val logging = - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC - } - - private val client: OkHttpClient = - OkHttpClient.Builder() - .addInterceptor(logging) - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(15, TimeUnit.SECONDS) - .build() - - private fun buildWebSocketUrl(httpApiBaseUrl: String): String { - // Expecting something like: http://host:port/api/ - var url = httpApiBaseUrl - // Ensure trailing slash removed for manipulation - if (url.endsWith("/")) url = url.dropLast(1) - - // Replace scheme - url = - when { - url.startsWith("https://") -> "wss://" + url.removePrefix("https://") - url.startsWith("http://") -> "ws://" + url.removePrefix("http://") - else -> "ws://$url" // best effort - } - - // Replace trailing /api with /api/ws - return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws" - } - - suspend fun sync( - context: Context, - request: SyncRequest, - ): Result = - withContext(Dispatchers.IO) { - val prefs = PreferencesManager.getInstance(context) - val baseUrl = prefs.getServerUrl() - val password = prefs.getServerPassword() - val wsUrl = buildWebSocketUrl(baseUrl) - val messageId = UUID.randomUUID().toString() - - val deferred = CompletableDeferred>() - var socket: WebSocket? = null - - val httpRequestBuilder = Request.Builder().url(wsUrl) - if (!password.isNullOrBlank()) { - httpRequestBuilder.addHeader("Authorization", "Bearer $password") - } - val httpRequest = httpRequestBuilder.build() - - val listener = - object : WebSocketListener() { - override fun onOpen( - webSocket: WebSocket, - response: Response, - ) { - // Send sync message when opened - val envelope = - MessageEnvelope( - id = messageId, - type = "sync", - payload = request, - ) - val json = gson.toJson(envelope) - webSocket.send(json) - } - - override fun onMessage( - webSocket: WebSocket, - text: String, - ) { - try { - val root = gson.fromJson(text, JsonObject::class.java) - val type = root.get("type")?.asString - val id = root.get("id")?.asString - if (type == "syncResponse" && id == messageId) { - val payload = root.getAsJsonObject("payload") - val resp = gson.fromJson(payload, SyncResponse::class.java) - if (!deferred.isCompleted) { - deferred.complete(Result.success(resp)) - webSocket.close(1000, "done") - } - } else if (type == "error" && !deferred.isCompleted) { - val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error" - deferred.complete(Result.failure(Exception(msg))) - webSocket.close(1002, "error") - } - } catch (e: Exception) { - if (!deferred.isCompleted) { - deferred.complete(Result.failure(e)) - } - } - } - - override fun onClosing( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - // 1008 indicates policy violation, used here for Unauthorized - if (!deferred.isCompleted) { - if (code == 1008) { - deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)"))) - } else { - deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason"))) - } - } - } - - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response?, - ) { - if (!deferred.isCompleted) { - deferred.complete(Result.failure(t)) - } - } - } - - try { - socket = client.newWebSocket(httpRequest, listener) - // Wait up to 30s for response (WS roundtrip) - val result = withTimeout(30_000) { deferred.await() } - return@withContext result - } catch (e: Exception) { - Logger.w("WS", "Falling back to HTTP sync: ${e.message}") - return@withContext Result.failure(e) - } finally { - // OkHttp cleans up sockets automatically when no references remain - // but we try to close if still open - socket?.close(1000, null) - } - } -} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index f23be5e..e8ad3d5 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -25,6 +25,10 @@ class PreferencesManager(context: Context) { private val _sortOrder = MutableStateFlow(getSortOrder()) val sortOrder: StateFlow = _sortOrder.asStateFlow() + // Sync polling interval in milliseconds (configurable) + private val _syncPollIntervalMs = MutableStateFlow(getSyncPollIntervalMs()) + val syncPollIntervalMs: StateFlow = _syncPollIntervalMs.asStateFlow() + fun getServerUrl(): String { return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL } @@ -124,6 +128,18 @@ class PreferencesManager(context: Context) { _sortOrder.value = normalized } + // Sync interval: read/write with sensible bounds to avoid too-aggressive polling + fun getSyncPollIntervalMs(): Long { + val raw = prefs.getLong(KEY_SYNC_POLL_INTERVAL_MS, DEFAULT_SYNC_POLL_INTERVAL_MS) + return raw.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS) + } + + fun setSyncPollIntervalMs(ms: Long) { + val clamped = ms.coerceIn(MIN_SYNC_POLL_INTERVAL_MS, MAX_SYNC_POLL_INTERVAL_MS) + prefs.edit().putLong(KEY_SYNC_POLL_INTERVAL_MS, clamped).apply() + _syncPollIntervalMs.value = clamped + } + companion object { private const val KEY_SERVER_URL = "server_url" private const val KEY_FIRST_RUN = "first_run" @@ -133,7 +149,11 @@ class PreferencesManager(context: Context) { private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_SORT_ORDER = "sort_order" + private const val KEY_SYNC_POLL_INTERVAL_MS = "sync_poll_interval_ms" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" + private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 5000L + private const val MIN_SYNC_POLL_INTERVAL_MS = 250L + private const val MAX_SYNC_POLL_INTERVAL_MS = 600_000L // 10 minutes const val THEME_MODE_SYSTEM = "system" const val THEME_MODE_LIGHT = "light" const val THEME_MODE_DARK = "dark" diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 01ec35e..52945b4 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.room.withTransaction import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.SyncRequest -import com.collabtable.app.data.api.WebSocketSyncClient import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.utils.Logger import kotlinx.coroutines.Dispatchers @@ -60,25 +59,19 @@ class SyncRepository(context: Context) { itemValues = localValues, ) - // Try WebSocket first; on failure, fall back to HTTP - val wsResult = WebSocketSyncClient.sync(appContext, syncRequest) - val syncResponse = - if (wsResult.isSuccess) { - wsResult.getOrThrow() - } else { - val response = api.sync(syncRequest) - if (!response.isSuccessful) { - if (response.code() == 401) { - // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. - Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") - setLastSyncTimestamp(0) - return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) - } - Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") - return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) - } - response.body()!! + // HTTP-only sync + val response = api.sync(syncRequest) + if (!response.isSuccessful) { + if (response.code() == 401) { + // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. + Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") + setLastSyncTimestamp(0) + return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) } + Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") + return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + } + val syncResponse = response.body()!! // Only log receive when there are actual server changes val inTotal = syncResponse.lists.size + diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index 1f96a1a..18beeeb 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText @@ -2522,11 +2523,8 @@ fun ManageColumnsDialog( val reorderState = rememberReorderableLazyListState( onMove = { from, to -> - // Map from Lazy positions (with dividers) to data indices. - // Insert AFTER the upper item when hovering between rows. - val fromData = from.index / 2 - val toData = (to.index + 1) / 2 - reorderedFields.move(fromData, toData) + // Indices from reorderable refer to draggable items only + reorderedFields.move(from.index, to.index) }, ) @@ -2540,53 +2538,41 @@ fun ManageColumnsDialog( contentPadding = PaddingValues(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { - // Interleave dividers as separate Lazy items so dividers don't move with dragged rows - items( - count = if (reorderedFields.isEmpty()) 0 else reorderedFields.size * 2 - 1, - key = { pos -> - if (pos % 2 == 0) reorderedFields[pos / 2].id else "divider-$pos" - }, - ) { pos -> - if (pos % 2 == 1) { - Divider() - } else { - val index = pos / 2 - val field = reorderedFields[index] - ReorderableItem( - reorderState, - key = field.id, - ) { _ -> - Box( - modifier = Modifier.animateItemPlacement(), - ) { - ColumnItem( - field = field, - onEdit = { fieldToEdit = field }, - onDelete = { fieldToDelete = field }, - onMoveUp = { - if (index > 0) { - val item = reorderedFields.removeAt(index) - reorderedFields.add(index - 1, item) - } - }, - onMoveDown = { - if (index < reorderedFields.size - 1) { - val item = reorderedFields.removeAt(index) - reorderedFields.add(index + 1, item) - } - }, - canMoveUp = index > 0, - canMoveDown = index < reorderedFields.size - 1, - dragHandle = { - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = "Reorder", - modifier = Modifier.detectReorder(reorderState), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } + itemsIndexed(reorderedFields, key = { _, field -> field.id }) { index, field -> + ReorderableItem( + reorderState, + key = field.id, + ) { _ -> + Box( + modifier = Modifier.animateItemPlacement(), + ) { + ColumnItem( + field = field, + onEdit = { fieldToEdit = field }, + onDelete = { fieldToDelete = field }, + onMoveUp = { + if (index > 0) { + val item = reorderedFields.removeAt(index) + reorderedFields.add(index - 1, item) + } + }, + onMoveDown = { + if (index < reorderedFields.size - 1) { + val item = reorderedFields.removeAt(index) + reorderedFields.add(index + 1, item) + } + }, + canMoveUp = index > 0, + canMoveDown = index < reorderedFields.size - 1, + dragHandle = { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "Reorder", + modifier = Modifier.detectReorder(reorderState), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) } } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt index 526481b..d1417ac 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -65,9 +65,11 @@ class ListDetailViewModel( private fun startPeriodicSync() { viewModelScope.launch { + val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context) while (true) { performSync() - kotlinx.coroutines.delay(5000) // Sync every 5 seconds + val delayMs = prefs.getSyncPollIntervalMs() + kotlinx.coroutines.delay(delayMs) } } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 6c6c803..13b0cf7 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sort import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -173,11 +172,8 @@ fun ListsScreen( onMove = { from, to -> dragging = true val newList = working.toMutableList() - // Map from Lazy positions (with dividers) to data indices. - // to.index can be odd (between items). Insert AFTER the upper item for better feel. - val fromData = from.index / 2 - val toData = (to.index + 1) / 2 - newList.move(fromData, toData) + // Indices provided by reorderable correspond to draggable items only (dividers ignored) + newList.move(from.index, to.index) working = newList }, onDragEnd = { _, _ -> @@ -195,35 +191,23 @@ fun ListsScreen( contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { - // Interleave dividers as separate list items so they don't move with dragged rows - items( - count = if (working.isEmpty()) 0 else working.size * 2 - 1, - key = { pos -> - if (pos % 2 == 0) working[pos / 2].id else "divider-$pos" - }, - ) { pos -> - if (pos % 2 == 1) { - Divider() - } else { - val index = pos / 2 - val list = working[index] - ReorderableItem(reorderState, key = list.id) { _ -> - Box(modifier = Modifier.animateItemPlacement()) { - ListItem( - list = list, - onListClick = { onNavigateToList(list.id) }, - onEditClick = { listToEdit = list }, - onDeleteClick = { listToDelete = list }, - dragHandle = { - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = "Reorder", - modifier = Modifier.detectReorder(reorderState), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } + itemsIndexed(working, key = { _, it -> it.id }) { _, list -> + ReorderableItem(reorderState, key = list.id) { _ -> + Box(modifier = Modifier.animateItemPlacement()) { + ListItem( + list = list, + onListClick = { onNavigateToList(list.id) }, + onEditClick = { listToEdit = list }, + onDeleteClick = { listToDelete = list }, + dragHandle = { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "Reorder", + modifier = Modifier.detectReorder(reorderState), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) } } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index ac61682..34a0e1e 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -99,8 +99,10 @@ class ListsViewModel( } private suspend fun startPeriodicSync() { + val prefs = com.collabtable.app.data.preferences.PreferencesManager.getInstance(context) while (true) { - kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync + val delayMs = prefs.getSyncPollIntervalMs() + kotlinx.coroutines.delay(delayMs) performSync() } } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index ec2adc2..c244df9 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch +import androidx.compose.material3.TextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -72,6 +73,9 @@ fun SettingsScreen( val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) } var showLeaveDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) } + val syncIntervalMs by preferencesManager.syncPollIntervalMs.collectAsState() + var syncIntervalInput by remember(syncIntervalMs) { mutableStateOf(syncIntervalMs.toString()) } + var syncIntervalError by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -216,6 +220,72 @@ fun SettingsScreen( } } + // Sync settings + Text( + text = "Sync", + style = MaterialTheme.typography.titleMedium, + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "HTTP polling interval (ms)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + value = syncIntervalInput, + onValueChange = { + syncIntervalInput = it.filter { ch -> ch.isDigit() } + syncIntervalError = null + }, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text("5000") }, + ) + Button(onClick = { + val parsed = syncIntervalInput.toLongOrNull() + if (parsed == null) { + syncIntervalError = "Enter a valid number" + } else { + // Bounds are enforced in setter + preferencesManager.setSyncPollIntervalMs(parsed) + syncIntervalError = null + } + }) { + Text("Apply") + } + } + if (syncIntervalError != null) { + Text( + text = syncIntervalError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + ) + } else { + Text( + text = "Current: ${'$'}syncIntervalMs ms (min 250, max 600000)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Card( modifier = Modifier.fillMaxWidth(), colors = diff --git a/CollabTableServer/package.json b/CollabTableServer/package.json index 85bfc4c..8b832c3 100644 --- a/CollabTableServer/package.json +++ b/CollabTableServer/package.json @@ -19,10 +19,9 @@ "cors": "^2.8.5", "better-sqlite3": "^11.0.0", "dotenv": "^16.3.1", - "ws": "^8.16.0", - "@typescript-eslint/eslint-plugin": "^7.0.2", - "@typescript-eslint/parser": "^7.0.2", - "eslint": "^8.57.0", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.3.3", "pg": "^8.11.3" @@ -32,7 +31,6 @@ "@types/cors": "^2.8.17", "@types/node": "^20.10.6", "@types/better-sqlite3": "^7.6.11", - "@types/ws": "^8.5.10", "@types/pg": "^8.10.2", "typescript": "^5.3.3", "ts-node-dev": "^2.0.0" diff --git a/CollabTableServer/src/index.ts b/CollabTableServer/src/index.ts index bc5b6e1..9b19172 100644 --- a/CollabTableServer/src/index.ts +++ b/CollabTableServer/src/index.ts @@ -8,8 +8,6 @@ import fieldRoutes from './routes/fieldRoutes'; import itemRoutes from './routes/itemRoutes'; import syncRoutes from './routes/syncRoutes'; import webRoutes from './routes/webRoutes'; -import { createServer } from 'http'; -import { initWebSocket } from './ws'; dotenv.config(); @@ -48,9 +46,8 @@ app.use('/', webRoutes); console.log('- Items'); console.log('- ItemValues'); - const server = createServer(app); - initWebSocket(server); - server.listen(PORT, () => { + // Start HTTP server (WebSocket removed) + app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase(); diff --git a/CollabTableServer/src/ws.ts b/CollabTableServer/src/ws.ts deleted file mode 100644 index 1e4dff5..0000000 --- a/CollabTableServer/src/ws.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Server as HttpServer, IncomingMessage } from 'http'; -import { WebSocketServer, WebSocket } from 'ws'; -import { dbAdapter } from './db'; - -interface SyncRequest { - lastSyncTimestamp: number; - lists: any[]; - fields: any[]; - items: any[]; - itemValues: any[]; -} - -interface MessageEnvelope { - id?: string; - type: string; - payload?: T; -} - -function checkAuth(req: any): boolean { - const serverPassword = process.env.SERVER_PASSWORD; - if (!serverPassword) return true; // auth disabled - const auth = req.headers?.authorization as string | undefined; - if (!auth) return false; - const parts = auth.split(' '); - return parts.length === 2 && parts[0] === 'Bearer' && parts[1] === serverPassword; -} - -const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; -const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; -const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; -const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt'; - -async function processSync(data: SyncRequest) { - await dbAdapter.transaction(async (tx) => { - for (const list of data.lists || []) { - await tx.execute(UPSERT_LIST, [list.id, list.name, list.createdAt, list.updatedAt, list.isDeleted ? 1 : 0]); - } - for (const field of data.fields || []) { - await tx.execute(UPSERT_FIELD, [field.id, field.name, field.fieldType, field.fieldOptions, field.listId, field.order, field.createdAt, field.updatedAt, field.isDeleted ? 1 : 0]); - } - for (const item of data.items || []) { - await tx.execute(UPSERT_ITEM, [item.id, item.listId, item.createdAt, item.updatedAt, item.isDeleted ? 1 : 0]); - } - for (const val of data.itemValues || []) { - await tx.execute(UPSERT_ITEM_VALUE, [val.id, val.itemId, val.fieldId, val.value, val.updatedAt]); - } - }); - - let serverLists: any[], serverFields: any[], serverItems: any[], serverItemValues: any[]; - const since = data.lastSyncTimestamp; - if (since === 0) { - serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0'); - serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0'); - serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0'); - serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values'); - } else { - serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [since]); - serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [since]); - serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [since]); - serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [since]); - } - - serverLists = serverLists.map(l => ({ ...l, isDeleted: !!l.isDeleted })); - serverFields = serverFields.map(f => ({ ...f, isDeleted: !!f.isDeleted })); - serverItems = serverItems.map(i => ({ ...i, isDeleted: !!i.isDeleted })); - - return { - lists: serverLists, - fields: serverFields, - items: serverItems, - itemValues: serverItemValues, - serverTimestamp: Date.now(), - }; -} - -export function initWebSocket(server: HttpServer) { - const wss = new WebSocketServer({ server, path: '/api/ws' }); - console.log('WebSocket server initialized at /api/ws'); - - wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { - if (!checkAuth(req)) { - ws.close(1008, 'Unauthorized'); - return; - } - - ws.on('message', async (raw: Buffer) => { - try { - const msg = JSON.parse(raw.toString()) as MessageEnvelope; - if (msg.type === 'ping') { - const resp: MessageEnvelope = { type: 'pong', id: msg.id }; - ws.send(JSON.stringify(resp)); - return; - } - if (msg.type === 'sync') { - const respData = await processSync(msg.payload as SyncRequest); - const resp: MessageEnvelope = { type: 'syncResponse', id: msg.id, payload: respData }; - ws.send(JSON.stringify(resp)); - return; - } - } catch (e) { - const err: MessageEnvelope = { type: 'error', payload: { message: 'Invalid message' } }; - ws.send(JSON.stringify(err)); - } - }); - }); -}