Refactor database access to use a unified dbAdapter for SQLite and PostgreSQL

- Replaced direct database calls with dbAdapter methods in fieldRoutes, itemRoutes, listRoutes, syncRoutes, and webRoutes.
- Implemented a new db.ts file to handle database interactions for both SQLite and PostgreSQL.
- Added WebSocket support for real-time synchronization in WebSocketSyncClient and ws.ts.
- Updated TypeScript configuration to include WebSocket types.
- Created a new WebSocket server for handling sync requests and responses.
- Ensured compatibility with both SQLite and PostgreSQL by using parameterized queries.
This commit is contained in:
2025-10-25 17:02:42 +02:00
parent 22ec511ca7
commit e5e72a39ac
24 changed files with 1183 additions and 395 deletions
+8 -8
View File
@@ -1,11 +1,11 @@
# CollabTable Android App # CollabTable Android App
A collaborative list management Android application built with Jetpack Compose and Material 3. A collaborative table management Android application built with Jetpack Compose and Material 3.
## Features ## Features
- Create and manage multiple lists - Create and manage multiple tables
- Add custom fields to lists (name, link, price, category, etc.) - Add custom fields to tables (name, link, price, category, etc.)
- Add items with values for each field - Add items with values for each field
- Beautiful Material 3 design with dynamic colors - Beautiful Material 3 design with dynamic colors
- Local Room database for offline support - Local Room database for offline support
@@ -36,7 +36,7 @@ A collaborative list management Android application built with Jetpack Compose a
The app includes a Settings screen where you can configure the server URL: The app includes a Settings screen where you can configure the server URL:
1. Tap the Settings icon (⚙️) in the top bar of the Lists screen 1. Tap the Settings icon (⚙️) in the top bar of the Tables screen
2. Enter your server URL 2. Enter your server URL
3. Tap "Save" 3. Tap "Save"
@@ -75,13 +75,13 @@ app/src/main/java/com/collabtable/app/
## Usage ## Usage
### Creating a List ### Creating a Table
1. Tap the + button on the main screen 1. Tap the + button on the main screen
2. Enter a list name 2. Enter a table name
3. Tap "Save" 3. Tap "Save"
### Adding Fields ### Adding Fields
1. Open a list 1. Open a table
2. Tap the + icon in the top bar 2. Tap the + icon in the top bar
3. Enter a field name (e.g., "Name", "Price", "Category") 3. Enter a field name (e.g., "Name", "Price", "Category")
4. Tap "Add" 4. Tap "Add"
@@ -92,7 +92,7 @@ app/src/main/java/com/collabtable/app/
3. Values are saved automatically as you type 3. Values are saved automatically as you type
### Deleting ### Deleting
- Tap the delete icon next to any list, field, or item - Tap the delete icon next to any table, field, or item
- Confirm the deletion - Confirm the deletion
## Synchronization ## Synchronization
@@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.navigation.AppNavigation import com.collabtable.app.ui.navigation.AppNavigation
import com.collabtable.app.ui.theme.CollabTableTheme import com.collabtable.app.ui.theme.CollabTableTheme
@@ -14,7 +17,18 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
CollabTableTheme { val prefs = PreferencesManager.getInstance(this)
val themeMode by prefs.themeMode.collectAsState()
val dynamicColor by prefs.dynamicColor.collectAsState()
val amoledDark by prefs.amoledDark.collectAsState()
val darkTheme = when (themeMode) {
PreferencesManager.THEME_MODE_LIGHT -> false
PreferencesManager.THEME_MODE_DARK -> true
else -> androidx.compose.foundation.isSystemInDarkTheme()
}
CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -0,0 +1,142 @@
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)
.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<SyncResponse> = 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<Result<SyncResponse>>()
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 15s for response
val result = withTimeout(15_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)
}
}
}
@@ -13,6 +13,15 @@ class PreferencesManager(context: Context) {
private val _serverUrl = MutableStateFlow(getServerUrl()) private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow() val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled())
val dynamicColor: StateFlow<Boolean> = _dynamicColor.asStateFlow()
private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
fun getServerUrl(): String { fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
} }
@@ -64,12 +73,50 @@ class PreferencesManager(context: Context) {
_serverUrl.value = getServerUrl() _serverUrl.value = getServerUrl()
} }
// Theme settings
fun getThemeMode(): String {
return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
}
fun setThemeMode(mode: String) {
val normalized = when (mode.lowercase()) {
THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase()
else -> THEME_MODE_SYSTEM
}
prefs.edit().putString(KEY_THEME_MODE, normalized).apply()
_themeMode.value = normalized
}
fun isDynamicColorEnabled(): Boolean {
return prefs.getBoolean(KEY_DYNAMIC_COLOR, true)
}
fun setDynamicColorEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply()
_dynamicColor.value = enabled
}
fun isAmoledDarkEnabled(): Boolean {
return prefs.getBoolean(KEY_AMOLED_DARK, false)
}
fun setAmoledDarkEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply()
_amoledDark.value = enabled
}
companion object { companion object {
private const val KEY_SERVER_URL = "server_url" private const val KEY_SERVER_URL = "server_url"
private const val KEY_FIRST_RUN = "first_run" private const val KEY_FIRST_RUN = "first_run"
private const val KEY_SERVER_PASSWORD = "server_password" private const val KEY_SERVER_PASSWORD = "server_password"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val KEY_AMOLED_DARK = "amoled_dark"
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
const val THEME_MODE_SYSTEM = "system"
const val THEME_MODE_LIGHT = "light"
const val THEME_MODE_DARK = "dark"
@Volatile @Volatile
private var instance: PreferencesManager? = null private var instance: PreferencesManager? = null
@@ -2,6 +2,7 @@ package com.collabtable.app.data.repository
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.WebSocketSyncClient
import com.collabtable.app.data.api.SyncRequest import com.collabtable.app.data.api.SyncRequest
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
@@ -55,82 +56,89 @@ class SyncRepository(context: Context) {
itemValues = localValues itemValues = localValues
) )
val response = api.sync(syncRequest) // Try WebSocket first; on failure, fall back to HTTP
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
if (response.isSuccessful) { val syncResponse = if (wsResult.isSuccess) {
val syncResponse = response.body()!! wsResult.getOrThrow()
// Only log receive when there are actual server changes
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
if (inTotal > 0) {
Logger.i(
"Sync",
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
)
}
// Apply server changes to local database atomically in correct order
database.withTransaction {
// 1) Upsert lists first
database.listDao().insertLists(syncResponse.lists)
// Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet()
// 2) Filter and upsert fields whose parent list exists
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
if (filteredFields.size != syncResponse.fields.size) {
val dropped = syncResponse.fields.size - filteredFields.size
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
}
if (filteredFields.isNotEmpty()) {
database.fieldDao().insertFields(filteredFields)
}
// 3) Filter and upsert items whose parent list exists
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
if (filteredItems.size != syncResponse.items.size) {
val dropped = syncResponse.items.size - filteredItems.size
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
}
if (filteredItems.isNotEmpty()) {
database.itemDao().insertItems(filteredItems)
}
// 4) Filter item values to only those whose parents (item and field) exist locally
val existingItemIds = database.itemDao().getAllItemIds().toSet()
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues = syncResponse.itemValues.filter { v ->
v.itemId in existingItemIds && v.fieldId in existingFieldIds
}
if (filteredValues.size != syncResponse.itemValues.size) {
val dropped = syncResponse.itemValues.size - filteredValues.size
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
}
if (filteredValues.isNotEmpty()) {
database.itemValueDao().insertValues(filteredValues)
}
}
// Update last sync timestamp
setLastSyncTimestamp(syncResponse.serverTimestamp)
if (isInitialSync) {
Logger.i("Sync", "[SYNC] Initial sync completed")
}
return@withContext Result.success(Unit)
} else { } else {
if (response.code() == 401) { val response = api.sync(syncRequest)
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. if (!response.isSuccessful) {
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") if (response.code() == 401) {
setLastSyncTimestamp(0) // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) 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()}"))
} }
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") response.body()!!
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
} }
// Only log receive when there are actual server changes
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
if (inTotal > 0) {
Logger.i(
"Sync",
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
)
}
// Apply server changes to local database atomically in correct order
database.withTransaction {
// 1) Upsert lists first
database.listDao().insertLists(syncResponse.lists)
// Build set of existing list ids to guard child inserts
val existingListIds = database.listDao().getAllListIds().toSet()
// 2) Filter and upsert fields whose parent list exists
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
if (filteredFields.size != syncResponse.fields.size) {
val dropped = syncResponse.fields.size - filteredFields.size
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
}
if (filteredFields.isNotEmpty()) {
database.fieldDao().insertFields(filteredFields)
}
// 3) Filter and upsert items whose parent list exists
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
if (filteredItems.size != syncResponse.items.size) {
val dropped = syncResponse.items.size - filteredItems.size
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
}
if (filteredItems.isNotEmpty()) {
database.itemDao().insertItems(filteredItems)
}
// 4) Filter item values to only those whose parents (item and field) exist locally
val existingItemIds = database.itemDao().getAllItemIds().toSet()
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues = syncResponse.itemValues.filter { v ->
v.itemId in existingItemIds && v.fieldId in existingFieldIds
}
if (filteredValues.size != syncResponse.itemValues.size) {
val dropped = syncResponse.itemValues.size - filteredValues.size
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
}
if (filteredValues.isNotEmpty()) {
database.itemValueDao().insertValues(filteredValues)
}
}
// Update last sync timestamp
setLastSyncTimestamp(syncResponse.serverTimestamp)
if (isInitialSync) {
Logger.i("Sync", "[SYNC] Initial sync completed")
}
return@withContext Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
// If WS indicated unauthorized, align behavior with HTTP path
if (e.message?.contains("Unauthorized") == true) {
setLastSyncTimestamp(0)
}
Logger.e("Sync", "[ERROR] Sync error: ${e.message}") Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
return@withContext Result.failure(e) return@withContext Result.failure(e)
} }
@@ -103,7 +103,7 @@ fun ListsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = "Tap + to create your first list", text = "Tap + to create your first table",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -268,7 +268,7 @@ private fun RenameListDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Rename List") }, title = { Text("Rename Table") },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = listName, value = listName,
@@ -54,13 +54,13 @@ class ListsViewModel(
private suspend fun performSync() { private suspend fun performSync() {
val result = syncRepository.performSync() val result = syncRepository.performSync()
result.onFailure { error -> result.onFailure { error ->
Logger.e("Lists", "❌ Sync failed: ${error.message}") Logger.e("Tables", "❌ Sync failed: ${error.message}")
} }
} }
fun createList(name: String) { fun createList(name: String) {
viewModelScope.launch { viewModelScope.launch {
Logger.i("Lists", " Creating list: \"$name\"") Logger.i("Tables", " Creating table: \"$name\"")
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val newList = CollabList( val newList = CollabList(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -78,7 +78,7 @@ class ListsViewModel(
viewModelScope.launch { viewModelScope.launch {
val list = database.listDao().getListById(listId) val list = database.listDao().getListById(listId)
if (list != null && newName.isNotBlank()) { if (list != null && newName.isNotBlank()) {
Logger.i("Lists", "✏️ Renaming: \"${list.name}\"\"$newName\"") Logger.i("Tables", "✏️ Renaming table: \"${list.name}\"\"$newName\"")
database.listDao().updateList( database.listDao().updateList(
list.copy( list.copy(
name = newName.trim(), name = newName.trim(),
@@ -95,7 +95,7 @@ class ListsViewModel(
viewModelScope.launch { viewModelScope.launch {
val list = database.listDao().getListById(listId) val list = database.listDao().getListById(listId)
if (list != null) { if (list != null) {
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"") Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
database.listDao().softDeleteList(listId, System.currentTimeMillis()) database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting // Sync immediately after deleting
performSync() performSync()
@@ -105,7 +105,7 @@ class ListsViewModel(
fun manualSync() { fun manualSync() {
viewModelScope.launch { viewModelScope.launch {
Logger.i("Lists", "🔄 Manual sync requested") Logger.i("Tables", "🔄 Manual sync requested")
_isLoading.value = true _isLoading.value = true
performSync() performSync()
_isLoading.value = false _isLoading.value = false
@@ -37,7 +37,6 @@ fun ServerSetupScreen(
LaunchedEffect(validationResult) { LaunchedEffect(validationResult) {
if (validationResult == true) { if (validationResult == true) {
// Validation successful, navigate to main screen
onSetupComplete() onSetupComplete()
} }
} }
@@ -57,34 +56,16 @@ fun ServerSetupScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(24.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(4.dp))
// Welcome text
Text(
text = "Welcome to CollabTable!",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Text(
text = "Please configure your server connection to get started.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Server URL input
OutlinedTextField( OutlinedTextField(
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
label = { Text("Server URL") }, label = { Text("Server Hostname") },
placeholder = { Text("example.com or 10.0.2.2:3000") }, placeholder = { Text("example.com or 10.0.2.2:3000") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isValidating, enabled = !isValidating,
@@ -95,7 +76,7 @@ fun ServerSetupScreen(
text = validationError ?: "", text = validationError ?: "",
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
else -> Text("Enter hostname with optional port (default: 80/443)") else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/")
} }
}, },
trailingIcon = { trailingIcon = {
@@ -118,7 +99,6 @@ fun ServerSetupScreen(
} }
) )
// Server Password input
OutlinedTextField( OutlinedTextField(
value = serverPassword, value = serverPassword,
onValueChange = { serverPassword = it }, onValueChange = { serverPassword = it },
@@ -136,16 +116,11 @@ fun ServerSetupScreen(
) )
} }
}, },
supportingText = { supportingText = { Text("Server password for authentication") }
Text("Password required for server authentication")
}
) )
// Validate and continue button
Button( Button(
onClick = { onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim())
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
) { ) {
@@ -158,66 +133,12 @@ fun ServerSetupScreen(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Validating...") Text("Validating...")
} else { } else {
Text("Validate and Continue") Text("Connect")
} }
} }
Spacer(modifier = Modifier.height(8.dp))
// Tips card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Connection Tips:",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• For Android Emulator: 10.0.2.2:3000",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• For Physical Device: YOUR_IP:3000",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• For domains: example.com (uses port 80/443)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Port is optional, defaults to 80 (HTTP) or 443 (HTTPS)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Just enter hostname:port, no need for http:// or /api/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Make sure the server is running and accessible",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Note about changing later
Text( Text(
text = "You can change this URL later in Settings", text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -15,6 +15,12 @@ import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.ui.Alignment
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -31,6 +37,10 @@ fun SettingsScreen(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val serverUrl = preferencesManager.getServerUrl() val serverUrl = preferencesManager.getServerUrl()
val themeMode by preferencesManager.themeMode.collectAsState()
val dynamicColor by preferencesManager.dynamicColor.collectAsState()
val amoledDark by preferencesManager.amoledDark.collectAsState()
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
var showLeaveDialog by remember { mutableStateOf(false) } var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
@@ -69,17 +79,17 @@ fun SettingsScreen(
) )
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text( Text(
text = "Connected to:", text = "Connected to",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = serverUrl, text = displayUrl,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
@@ -87,6 +97,70 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium
)
// Theme mode selector
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
label = { Text("System") },
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) }
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
label = { Text("Light") },
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) }
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_DARK,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
label = { Text("Dark") },
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) }
)
}
// Dynamic color toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Use Material 3 colors")
Text(
text = "Dynamic colors on supported devices",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
}
// AMOLED dark toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("AMOLED dark")
Text(
text = "Pure black background in dark mode",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
}
Button( Button(
onClick = { showLeaveDialog = true }, onClick = { showLeaveDialog = true },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -116,31 +190,31 @@ fun SettingsScreen(
) )
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text( Text(
text = "About Leave Server:", text = "Leaving removes local data",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = "Disconnects from the current server", text = "Disconnects and clears local database",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = "Clears stored server URL and password", text = "Clears stored server URL and password",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = "Deletes all local data (lists, fields, items)", text = "Deletes all local data (tables, fields, items)",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = "Returns to server setup screen", text = "Returns to server setup screen",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
@@ -211,3 +285,25 @@ fun SettingsScreen(
} }
} }
} }
private fun formatServerUrlForDisplay(raw: String): String {
var s = raw.trim()
if (s.isEmpty()) return ""
// Remove scheme
s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "")
// Remove trailing /api or /api/
s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "")
// Trim trailing /
s = s.trimEnd('/')
// Split authority and path (in case any path remains)
val firstSlash = s.indexOf('/')
val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s
val rest = if (firstSlash >= 0) s.substring(firstSlash) else ""
// Remove default ports from authority
val authorityStripped = when {
authority.endsWith(":80") -> authority.removeSuffix(":80")
authority.endsWith(":443") -> authority.removeSuffix(":443")
else -> authority
}
return authorityStripped + rest
}
@@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@@ -31,9 +32,10 @@ private val LightColorScheme = lightColorScheme(
fun CollabTableTheme( fun CollabTableTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
amoledDark: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { var colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@@ -41,6 +43,14 @@ fun CollabTableTheme(
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
if (darkTheme && amoledDark) {
colorScheme = colorScheme.copy(
background = Color.Black,
surface = Color.Black,
surfaceVariant = Color.Black
)
}
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -1,8 +1,8 @@
<resources> <resources>
<string name="app_name">CollabTable</string> <string name="app_name">CollabTable</string>
<string name="lists">Lists</string> <string name="lists">Tables</string>
<string name="create_list">Create List</string> <string name="create_list">Create Table</string>
<string name="list_name">List Name</string> <string name="list_name">Table Name</string>
<string name="add_field">Add Field</string> <string name="add_field">Add Field</string>
<string name="field_name">Field Name</string> <string name="field_name">Field Name</string>
<string name="add_item">Add Item</string> <string name="add_item">Add Item</string>
@@ -11,7 +11,7 @@
<string name="save">Save</string> <string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="edit">Edit</string> <string name="edit">Edit</string>
<string name="no_lists">No lists yet. Create one to get started!</string> <string name="no_lists">No tables yet. Create one to get started!</string>
<string name="no_items">No items yet</string> <string name="no_items">No items yet</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
@@ -19,7 +19,7 @@
<string name="sync">Sync</string> <string name="sync">Sync</string>
<string name="syncing">Syncing…</string> <string name="syncing">Syncing…</string>
<string name="sync_error">Sync error</string> <string name="sync_error">Sync error</string>
<string name="delete_list">Delete List</string> <string name="delete_list">Delete Table</string>
<string name="delete_field">Delete Field</string> <string name="delete_field">Delete Field</string>
<string name="delete_item">Delete Item</string> <string name="delete_item">Delete Item</string>
<string name="confirm_delete">Are you sure you want to delete this?</string> <string name="confirm_delete">Are you sure you want to delete this?</string>
Binary file not shown.
+32 -1
View File
@@ -1,4 +1,35 @@
#################################################################
# Server
#################################################################
PORT=3000 PORT=3000
DB_PATH=/data/collabtable.db
NODE_ENV=production NODE_ENV=production
SERVER_PASSWORD=your_secure_password_here SERVER_PASSWORD=your_secure_password_here
#################################################################
# Database backend selection
# - sqlite (default if not set)
# - postgres
#################################################################
# Choose the DB client; if omitted or not set to "postgres", SQLite is used
DB_CLIENT=sqlite
#################################################################
# SQLite configuration (used when DB_CLIENT=sqlite)
#################################################################
# Path to the SQLite database file. Default in code: ./data/collabtable.db
# For Docker, you may prefer a volume path like /data/collabtable.db
DB_PATH=/data/collabtable.db
#################################################################
# PostgreSQL configuration (used when DB_CLIENT=postgres)
# You can either provide a single DATABASE_URL or the PG* variables below
#################################################################
# Example URL: postgres://user:password@localhost:5432/collabtable
DATABASE_URL=
# If DATABASE_URL is not set, these individual settings are used:
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=
PGDATABASE=collabtable
+185 -2
View File
@@ -12,13 +12,17 @@
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2" "express": "^4.18.2",
"pg": "^8.11.3",
"ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/pg": "^8.10.2",
"@types/ws": "^8.5.10",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
@@ -183,6 +187,18 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.15.5",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz",
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -244,6 +260,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1418,6 +1444,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1431,6 +1546,45 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1832,6 +1986,15 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -2126,11 +2289,31 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
+5 -1
View File
@@ -16,13 +16,17 @@
"express": "^4.18.2", "express": "^4.18.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"dotenv": "^16.3.1" "dotenv": "^16.3.1",
"ws": "^8.16.0",
"pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/ws": "^8.5.10",
"@types/pg": "^8.10.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"ts-node-dev": "^2.0.0" "ts-node-dev": "^2.0.0"
} }
+266
View File
@@ -0,0 +1,266 @@
import dotenv from 'dotenv';
import BetterSqlite3, { Database as SqliteDB } from 'better-sqlite3';
import { Pool, PoolClient, QueryResult } from 'pg';
import path from 'path';
import fs from 'fs';
dotenv.config();
type Param = any;
type ExecResult = { changes: number };
type TxClient = {
query: (text: string, params?: any[]) => Promise<QueryResult<any>>;
};
interface DBAdapter {
queryAll(sql: string, params?: Param[]): Promise<any[]>;
queryOne(sql: string, params?: Param[]): Promise<any | undefined>;
execute(sql: string, params?: Param[]): Promise<ExecResult>;
transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T>;
initialize(): Promise<void>;
}
function ensureDataDir(dbPath: string) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function replaceQuotedIdentifiers(sql: string) {
// Convert backticks to double quotes for portability
return sql.replace(/`([^`]+)`/g, '"$1"');
}
function convertQMarksToPg(sql: string) {
// Replace each ? with $1, $2, ...
let idx = 0;
return sql.replace(/\?/g, () => `$${++idx}`);
}
// ---------- SQLite Adapter (wrapped async) ----------
class SqliteAdapter implements DBAdapter {
private db: SqliteDB;
constructor() {
const DB_PATH = process.env.DB_PATH || './data/collabtable.db';
ensureDataDir(DB_PATH);
this.db = new BetterSqlite3(DB_PATH);
this.db.pragma('foreign_keys = ON');
}
async initialize(): Promise<void> {
// Create tables and indexes
this.db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS fields (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fieldType TEXT NOT NULL,
fieldOptions TEXT,
listId TEXT NOT NULL,
"order" INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
CREATE TABLE IF NOT EXISTS item_values (
id TEXT PRIMARY KEY,
itemId TEXT NOT NULL,
fieldId TEXT NOT NULL,
value TEXT,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (itemId) REFERENCES items(id),
FOREIGN KEY (fieldId) REFERENCES fields(id)
);
CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);
CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);
CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);
CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);
`);
}
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
return stmt.all(...params);
}
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
return stmt.get(...params);
}
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
const stmt = this.db.prepare(replaceQuotedIdentifiers(sql));
const res = stmt.run(...params);
return { changes: res.changes || 0 };
}
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
const tx = this.db.transaction((innerFn: (tx: DBAdapter) => Promise<T>) => {
// We can reuse the same adapter since better-sqlite3 is transactional per connection
return innerFn(this);
});
return tx(fn);
}
}
// ---------- Postgres Adapter ----------
class PostgresAdapter implements DBAdapter {
private pool: Pool;
constructor() {
const connectionString = process.env.DATABASE_URL;
if (connectionString) {
this.pool = new Pool({ connectionString });
} else {
this.pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || '',
database: process.env.PGDATABASE || 'collabtable'
});
}
}
async initialize(): Promise<void> {
// Create tables if not exist, keeping schema aligned with sqlite (isDeleted INTEGER 0/1)
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(`
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS fields (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fieldType TEXT NOT NULL,
fieldOptions TEXT,
listId TEXT NOT NULL,
"order" INTEGER NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
createdAt BIGINT NOT NULL,
updatedAt BIGINT NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS item_values (
id TEXT PRIMARY KEY,
itemId TEXT NOT NULL,
fieldId TEXT NOT NULL,
value TEXT,
updatedAt BIGINT NOT NULL,
FOREIGN KEY (itemId) REFERENCES items(id),
FOREIGN KEY (fieldId) REFERENCES fields(id)
);
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
private async runQuery(sql: string, params: Param[] = [], client?: PoolClient): Promise<QueryResult<any>> {
// Replace backticks and convert ? to $1..$n
const text = convertQMarksToPg(replaceQuotedIdentifiers(sql));
const runner = client || this.pool;
// @ts-ignore - Pool and PoolClient share query signature
return runner.query(text, params);
}
async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
const res = await this.runQuery(sql, params);
return res.rows;
}
async queryOne(sql: string, params: Param[] = []): Promise<any | undefined> {
const res = await this.runQuery(sql, params);
return res.rows[0];
}
async execute(sql: string, params: Param[] = []): Promise<ExecResult> {
const res = await this.runQuery(sql, params);
return { changes: res.rowCount || 0 };
}
async transaction<T>(fn: (tx: DBAdapter) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const txAdapter: DBAdapter = {
initialize: async () => {},
queryAll: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows),
queryOne: (sql, params) => this.runQuery(sql, params, client).then(r => r.rows[0]),
execute: (sql, params) => this.runQuery(sql, params, client).then(r => ({ changes: r.rowCount || 0 })),
transaction: async (inner) => {
// Nested transactions are treated as flat for simplicity
return inner(txAdapter);
}
} as DBAdapter;
const result = await fn(txAdapter);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
const clientType = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
export const dbAdapter: DBAdapter = clientType === 'postgres' || clientType === 'postgresql'
? new PostgresAdapter()
: new SqliteAdapter();
export async function initializeDatabase() {
await dbAdapter.initialize();
}
export type { DBAdapter };
+30 -18
View File
@@ -1,13 +1,15 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { initializeDatabase } from './database'; import { initializeDatabase } from './db';
import { authenticatePassword } from './middleware/auth'; import { authenticatePassword } from './middleware/auth';
import listRoutes from './routes/listRoutes'; import listRoutes from './routes/listRoutes';
import fieldRoutes from './routes/fieldRoutes'; import fieldRoutes from './routes/fieldRoutes';
import itemRoutes from './routes/itemRoutes'; import itemRoutes from './routes/itemRoutes';
import syncRoutes from './routes/syncRoutes'; import syncRoutes from './routes/syncRoutes';
import webRoutes from './routes/webRoutes'; import webRoutes from './routes/webRoutes';
import { createServer } from 'http';
import { initWebSocket } from './ws';
dotenv.config(); dotenv.config();
@@ -36,23 +38,33 @@ app.use('/api', syncRoutes);
app.use('/', webRoutes); app.use('/', webRoutes);
// Initialize database and start server // Initialize database and start server
try { (async () => {
initializeDatabase(); try {
console.log('Database synchronized successfully'); await initializeDatabase();
console.log('Database models loaded:'); console.log('Database synchronized successfully');
console.log('- Lists'); console.log('Database models loaded:');
console.log('- Fields'); console.log('- Lists');
console.log('- Items'); console.log('- Fields');
console.log('- ItemValues'); console.log('- Items');
console.log('- ItemValues');
app.listen(PORT, () => { const server = createServer(app);
console.log(`Server is running on port ${PORT}`); initWebSocket(server);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); server.listen(PORT, () => {
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`); console.log(`Server is running on port ${PORT}`);
}); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
} catch (error) { const dbClient = (process.env.DB_CLIENT || process.env.DB_TYPE || 'sqlite').toLowerCase();
console.error('Database initialization error:', error); if (dbClient === 'postgres' || dbClient === 'postgresql') {
process.exit(1); const source = process.env.DATABASE_URL ? 'DATABASE_URL' : 'PGHOST/PGDATABASE';
} console.log(`Database: PostgreSQL (${source})`);
} else {
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
}
});
} catch (error) {
console.error('Database initialization error:', error);
process.exit(1);
}
})();
export default app; export default app;
+16 -19
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { db } from '../database'; import { dbAdapter } from '../db';
const router = Router(); const router = Router();
// Get fields for a list // Get fields for a list
router.get('/list/:listId', async (req: Request, res: Response) => { router.get('/list/:listId', async (req: Request, res: Response) => {
try { try {
const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId); const fields = await dbAdapter.queryAll('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY "order" ASC', [req.params.listId]);
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted })); const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
res.json(formattedFields); res.json(formattedFields);
} catch (error) { } catch (error) {
@@ -18,12 +18,11 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => {
try { try {
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body; const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(` await dbAdapter.execute(
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted) 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) [id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
`).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0); );
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]);
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id);
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted }); res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create field' }); res.status(500).json({ error: 'Failed to create field' });
@@ -35,17 +34,16 @@ router.put('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const { name, fieldType, fieldOptions, order } = req.body; const { name, fieldType, fieldOptions, order } = req.body;
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE fields 'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?',
SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ? [name, fieldType, fieldOptions, order, updatedAt, req.params.id]
WHERE id = ? );
`).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' }); return res.status(404).json({ error: 'Field not found' });
} }
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id); const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [req.params.id]);
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted }); res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update field' }); res.status(500).json({ error: 'Failed to update field' });
@@ -56,11 +54,10 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE fields 'UPDATE fields SET isDeleted = 1, updatedAt = ? WHERE id = ?',
SET isDeleted = 1, updatedAt = ? [updatedAt, req.params.id]
WHERE id = ? );
`).run(updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' }); return res.status(404).json({ error: 'Field not found' });
+22 -29
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { db } from '../database'; import { dbAdapter } from '../db';
const router = Router(); const router = Router();
// Get items for a list // Get items for a list
router.get('/list/:listId', async (req: Request, res: Response) => { router.get('/list/:listId', async (req: Request, res: Response) => {
try { try {
const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId); const items = await dbAdapter.queryAll('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC', [req.params.listId]);
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted })); const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json(formattedItems); res.json(formattedItems);
} catch (error) { } catch (error) {
@@ -17,7 +17,7 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
// Get item values // Get item values
router.get('/:itemId/values', async (req: Request, res: Response) => { router.get('/:itemId/values', async (req: Request, res: Response) => {
try { try {
const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId); const values = await dbAdapter.queryAll('SELECT * FROM item_values WHERE itemId = ?', [req.params.itemId]);
res.json(values); res.json(values);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch item values' }); res.status(500).json({ error: 'Failed to fetch item values' });
@@ -28,12 +28,11 @@ router.get('/:itemId/values', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => {
try { try {
const { id, listId, createdAt, updatedAt, isDeleted } = req.body; const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(` await dbAdapter.execute(
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
VALUES (?, ?, ?, ?, ?) [id, listId, createdAt, updatedAt, isDeleted ? 1 : 0]
`).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0); );
const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [id]);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted }); res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create item' }); res.status(500).json({ error: 'Failed to create item' });
@@ -44,15 +43,11 @@ router.post('/', async (req: Request, res: Response) => {
router.post('/values', async (req: Request, res: Response) => { router.post('/values', async (req: Request, res: Response) => {
try { try {
const { id, itemId, fieldId, value, updatedAt } = req.body; const { id, itemId, fieldId, value, updatedAt } = req.body;
db.prepare(` await dbAdapter.execute(
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = ?, updatedAt = ?',
VALUES (?, ?, ?, ?, ?) [id, itemId, fieldId, value, updatedAt, value, updatedAt]
ON CONFLICT(id) DO UPDATE SET );
value = ?, const itemValue = await dbAdapter.queryOne('SELECT * FROM item_values WHERE id = ?', [id]);
updatedAt = ?
`).run(id, itemId, fieldId, value, updatedAt, value, updatedAt);
const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id);
res.json(itemValue); res.json(itemValue);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to save item value' }); res.status(500).json({ error: 'Failed to save item value' });
@@ -63,17 +58,16 @@ router.post('/values', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE items 'UPDATE items SET updatedAt = ? WHERE id = ?',
SET updatedAt = ? [updatedAt, req.params.id]
WHERE id = ? );
`).run(updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' }); return res.status(404).json({ error: 'Item not found' });
} }
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id); const item = await dbAdapter.queryOne('SELECT * FROM items WHERE id = ?', [req.params.id]);
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted }); res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update item' }); res.status(500).json({ error: 'Failed to update item' });
@@ -84,11 +78,10 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE items 'UPDATE items SET isDeleted = 1, updatedAt = ? WHERE id = ?',
SET isDeleted = 1, updatedAt = ? [updatedAt, req.params.id]
WHERE id = ? );
`).run(updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' }); return res.status(404).json({ error: 'Item not found' });
+17 -21
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { db } from '../database'; import { dbAdapter } from '../db';
const router = Router(); const router = Router();
// Get all lists // Get all lists
router.get('/', async (req: Request, res: Response) => { router.get('/', async (req: Request, res: Response) => {
try { try {
const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all(); const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
// Convert isDeleted from INTEGER to BOOLEAN // Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
res.json(formattedLists); res.json(formattedLists);
@@ -18,7 +18,7 @@ router.get('/', async (req: Request, res: Response) => {
// Get single list // Get single list
router.get('/:id', async (req: Request, res: Response) => { router.get('/:id', async (req: Request, res: Response) => {
try { try {
const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id); const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ? AND isDeleted = 0', [req.params.id]);
if (!list) { if (!list) {
return res.status(404).json({ error: 'List not found' }); return res.status(404).json({ error: 'List not found' });
} }
@@ -32,12 +32,11 @@ router.get('/:id', async (req: Request, res: Response) => {
router.post('/', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => {
try { try {
const { id, name, createdAt, updatedAt, isDeleted } = req.body; const { id, name, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(` await dbAdapter.execute(
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?)',
VALUES (?, ?, ?, ?, ?) [id, name, createdAt, updatedAt, isDeleted ? 1 : 0]
`).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0); );
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [id]);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id);
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted }); res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create list' }); res.status(500).json({ error: 'Failed to create list' });
@@ -48,17 +47,15 @@ router.post('/', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE lists 'UPDATE lists SET name = ?, updatedAt = ? WHERE id = ?',
SET name = ?, updatedAt = ? [req.body.name, updatedAt, req.params.id]
WHERE id = ? );
`).run(req.body.name, updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' }); return res.status(404).json({ error: 'List not found' });
} }
const list = await dbAdapter.queryOne('SELECT * FROM lists WHERE id = ?', [req.params.id]);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id);
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted }); res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to update list' }); res.status(500).json({ error: 'Failed to update list' });
@@ -69,11 +66,10 @@ router.put('/:id', async (req: Request, res: Response) => {
router.delete('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const result = db.prepare(` const result = await dbAdapter.execute(
UPDATE lists 'UPDATE lists SET isDeleted = 1, updatedAt = ? WHERE id = ?',
SET isDeleted = 1, updatedAt = ? [updatedAt, req.params.id]
WHERE id = ? );
`).run(updatedAt, req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' }); return res.status(404).json({ error: 'List not found' });
+55 -94
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { db } from '../database'; import { dbAdapter } from '../db';
const router = Router(); const router = Router();
@@ -57,43 +57,11 @@ setInterval(() => {
}, 60000); // Changed from 30s to 60s }, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization) // Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() { // We'll perform upserts with positional params for cross-DB portability
return { 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';
upsertList: db.prepare(` 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';
INSERT INTO lists (id, name, createdAt, updatedAt, 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';
VALUES (@id, @name, @createdAt, @updatedAt, @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';
ON CONFLICT(id) DO UPDATE SET
name = @name,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertField: db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @fieldType, @fieldOptions, @listId, @order, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
name = @name,
fieldType = @fieldType,
fieldOptions = @fieldOptions,
\`order\` = @order,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItem: db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (@id, @listId, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItemValue: db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (@id, @itemId, @fieldId, @value, @updatedAt)
ON CONFLICT(id) DO UPDATE SET
value = @value,
updatedAt = @updatedAt
`)
};
}
router.post('/sync', async (req: Request, res: Response) => { router.post('/sync', async (req: Request, res: Response) => {
try { try {
@@ -144,65 +112,60 @@ router.post('/sync', async (req: Request, res: Response) => {
} }
// Save incoming data from client using a transaction // Save incoming data from client using a transaction
const transaction = db.transaction((data: SyncRequest) => { await dbAdapter.transaction(async (tx) => {
// Get prepared statements lazily if (lists && lists.length > 0) {
const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements(); for (const list of lists) {
await tx.execute(UPSERT_LIST, [
if (data.lists && data.lists.length > 0) { list.id,
for (const list of data.lists) { list.name,
upsertList.run({ list.createdAt,
id: list.id, list.updatedAt,
name: list.name, list.isDeleted ? 1 : 0
createdAt: list.createdAt, ]);
updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0
});
} }
} }
if (data.fields && data.fields.length > 0) { if (fields && fields.length > 0) {
for (const field of data.fields) { for (const field of fields) {
upsertField.run({ await tx.execute(UPSERT_FIELD, [
id: field.id, field.id,
name: field.name, field.name,
fieldType: field.fieldType, field.fieldType,
fieldOptions: field.fieldOptions, field.fieldOptions,
listId: field.listId, field.listId,
order: field.order, field.order,
createdAt: field.createdAt, field.createdAt,
updatedAt: field.updatedAt, field.updatedAt,
isDeleted: field.isDeleted ? 1 : 0 field.isDeleted ? 1 : 0
}); ]);
} }
} }
if (data.items && data.items.length > 0) { if (items && items.length > 0) {
for (const item of data.items) { for (const item of items) {
upsertItem.run({ await tx.execute(UPSERT_ITEM, [
id: item.id, item.id,
listId: item.listId, item.listId,
createdAt: item.createdAt, item.createdAt,
updatedAt: item.updatedAt, item.updatedAt,
isDeleted: item.isDeleted ? 1 : 0 item.isDeleted ? 1 : 0
}); ]);
} }
} }
if (data.itemValues && data.itemValues.length > 0) { if (itemValues && itemValues.length > 0) {
for (const value of data.itemValues) { for (const value of itemValues) {
upsertItemValue.run({ await tx.execute(UPSERT_ITEM_VALUE, [
id: value.id, value.id,
itemId: value.itemId, value.itemId,
fieldId: value.fieldId, value.fieldId,
value: value.value, value.value,
updatedAt: value.updatedAt value.updatedAt
}); ]);
} }
} }
}); });
transaction({ lastSyncTimestamp, lists, fields, items, itemValues });
// IMPORTANT: Get updates from server AFTER saving incoming data // IMPORTANT: Get updates from server AFTER saving incoming data
// This ensures clients receive back any data they just sent (for confirmation) // This ensures clients receive back any data they just sent (for confirmation)
// If lastSyncTimestamp is 0, get all data (initial sync) // If lastSyncTimestamp is 0, get all data (initial sync)
@@ -210,17 +173,15 @@ router.post('/sync', async (req: Request, res: Response) => {
let serverLists, serverFields, serverItems, serverItemValues; let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) { if (lastSyncTimestamp === 0) {
// Initial sync: send all non-deleted items (AFTER processing incoming data) serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0');
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all(); serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0');
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all(); serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0');
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all(); serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values');
serverItemValues = db.prepare('SELECT * FROM item_values').all();
} else { } else {
// Incremental sync: send all updates including deletions (use >= to include items updated exactly at lastSyncTimestamp) serverLists = await dbAdapter.queryAll('SELECT * FROM lists WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt >= ?').all(lastSyncTimestamp); serverFields = await dbAdapter.queryAll('SELECT * FROM fields WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt >= ?').all(lastSyncTimestamp); serverItems = await dbAdapter.queryAll('SELECT * FROM items WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt >= ?').all(lastSyncTimestamp); serverItemValues = await dbAdapter.queryAll('SELECT * FROM item_values WHERE updatedAt >= ?', [lastSyncTimestamp]);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt >= ?').all(lastSyncTimestamp);
} }
// Convert isDeleted from INTEGER (0/1) to BOOLEAN // Convert isDeleted from INTEGER (0/1) to BOOLEAN
+6 -5
View File
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { db } from '../database'; import { dbAdapter } from '../db';
const router = Router(); const router = Router();
@@ -306,10 +306,11 @@ router.get('/web/data', async (req: Request, res: Response) => {
res.setHeader('Pragma', 'no-cache'); res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0'); res.setHeader('Expires', '0');
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all(); // Exclude deleted lists and items from web UI
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all(); const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all(); const fields = await dbAdapter.queryAll('SELECT * FROM fields ORDER BY listId ASC, "order" ASC');
const values = db.prepare('SELECT * FROM item_values').all(); const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC');
const values = await dbAdapter.queryAll('SELECT * FROM item_values');
// Convert isDeleted from INTEGER to BOOLEAN // Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted })); const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
+106
View File
@@ -0,0 +1,106 @@
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<T = any> {
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));
}
});
});
}
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2020", "target": "ES2020",
"module": "commonjs", "module": "commonjs",
"lib": ["ES2020"], "lib": ["ES2020"],
"types": ["node"], "types": ["node", "ws"],
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,