feat: Implement list management routes with CRUD operations

feat: Add sync routes for data synchronization between client and server

feat: Create web routes for serving UI and data visualization

chore: Configure TypeScript for the server project

docs: Update README with project overview, features, and setup instructions

docs: Add server setup implementation details for first-run configuration

docs: Summarize updates and changes in the project, including new features and enhancements
This commit is contained in:
2025-10-24 20:27:18 +02:00
commit 9aa7ed8878
72 changed files with 8916 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
*.log
.env
.git
.gitignore
README.md
docker-compose.yml
Dockerfile
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
DB_PATH=/data/collabtable.db
NODE_ENV=production
SERVER_PASSWORD=your_secure_password_here
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
*.log
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Create data directory for SQLite
RUN mkdir -p /data
# Expose port
EXPOSE 3000
# Start the application
CMD ["npm", "start"]
+231
View File
@@ -0,0 +1,231 @@
# CollabTable Server
A collaborative list management server built with Node.js, Express, TypeScript, and SQLite.
## Features
- RESTful API for managing lists, fields, items, and values
- Real-time synchronization support
- SQLite database with Sequelize ORM
- Dockerized deployment with persistent storage
- TypeScript for type safety
## Prerequisites
- Docker and Docker Compose
- Or Node.js 20+ (for local development)
## Quick Start with Docker
1. Clone the repository
2. Navigate to the server directory:
```bash
cd CollabTableServer
```
3. Copy the example environment file:
```bash
cp .env.example .env
```
4. Start the services:
```bash
docker-compose up -d
```
The server will be available at `http://localhost:3000`
**Important:** The SQLite database is stored in a Docker volume named `sqlite_data`, which persists across container restarts and rebuilds.
## Local Development
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file:
```bash
cp .env.example .env
```
3. Update the database path in `.env` if needed:
```
DB_PATH=./data/collabtable.db
```
4. Start the development server:
```bash
npm run dev
```
## API Endpoints
### Lists
- `GET /api/lists` - Get all lists
- `GET /api/lists/:id` - Get a specific list
- `POST /api/lists` - Create a new list
- `PUT /api/lists/:id` - Update a list
- `DELETE /api/lists/:id` - Delete a list (soft delete)
### Fields
- `GET /api/fields/list/:listId` - Get all fields for a list
- `POST /api/fields` - Create a new field
- `PUT /api/fields/:id` - Update a field
- `DELETE /api/fields/:id` - Delete a field (soft delete)
### Items
- `GET /api/items/list/:listId` - Get all items for a list
- `GET /api/items/:itemId/values` - Get all values for an item
- `POST /api/items` - Create a new item
- `POST /api/items/values` - Create/update an item value
- `PUT /api/items/:id` - Update an item
- `DELETE /api/items/:id` - Delete an item (soft delete)
### Sync
- `POST /api/sync` - Synchronize data between client and server
### Health Check
- `GET /health` - Check server health
## Data Models
### List
```typescript
{
id: string;
name: string;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### Field
```typescript
{
id: string;
listId: string;
name: string;
order: number;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### Item
```typescript
{
id: string;
listId: string;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### ItemValue
```typescript
{
id: string;
itemId: string;
fieldId: string;
value: string;
updatedAt: number;
}
```
## Sync Protocol
The sync endpoint accepts a POST request with the following structure:
```json
{
"lastSyncTimestamp": 1234567890,
"lists": [...],
"fields": [...],
"items": [...],
"itemValues": [...]
}
```
The server responds with:
```json
{
"lists": [...],
"fields": [...],
"items": [...],
"itemValues": [...],
"serverTimestamp": 1234567890
}
```
The sync process:
1. Client sends all local changes since the last sync
2. Server saves the client's changes
3. Server returns all changes made on the server since the client's last sync
4. Client applies the server's changes locally
## Building for Production
Build the TypeScript code:
```bash
npm run build
```
Start the production server:
```bash
npm start
```
## Docker Commands
Start services:
```bash
docker-compose up -d
```
Stop services:
```bash
docker-compose down
```
View logs:
```bash
docker-compose logs -f
```
Rebuild and restart:
```bash
docker-compose up -d --build
```
## Environment Variables
- `PORT` - Server port (default: 3000)
- `DB_PATH` - Path to SQLite database file (default: /data/collabtable.db)
- `NODE_ENV` - Environment (development/production)
## Data Persistence
The SQLite database file is stored in a Docker volume, ensuring data persists across:
- Container restarts
- Container recreation
- Docker Compose down/up cycles
To backup your data:
```bash
docker cp collabtable-server:/data/collabtable.db ./backup.db
```
To restore data:
```bash
docker cp ./backup.db collabtable-server:/data/collabtable.db
docker-compose restart
```
## License
MIT
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
server:
build: .
container_name: collabtable-server
restart: unless-stopped
environment:
- PORT=3000
- DB_PATH=/data/collabtable.db
- NODE_ENV=production
ports:
- "3000:3000"
volumes:
- sqlite_data:/data
networks:
- collabtable-network
volumes:
sqlite_data:
networks:
collabtable-network:
driver: bridge
+2
View File
@@ -0,0 +1,2 @@
# Ensure data directory exists
mkdir -p /data
+2351
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "collabtable-server",
"version": "1.0.0",
"description": "Collaborative list management server",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["collaboration", "api", "express"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^11.0.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}
+83
View File
@@ -0,0 +1,83 @@
import path from 'path';
import fs from 'fs';
import BetterSqlite3 from 'better-sqlite3';
const DB_PATH = process.env.DB_PATH || './data/collabtable.db';
// Ensure the data directory exists
const dbDir = path.dirname(DB_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`Created database directory: ${dbDir}`);
}
console.log(`Using database at: ${DB_PATH}`);
// Create and export the database connection
export const db = new BetterSqlite3(DB_PATH);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create tables
export function initializeDatabase() {
// Create lists table
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 fields table
db.exec(`
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 items table
db.exec(`
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 item_values table
db.exec(`
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 indexes for better query performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId)`);
console.log('Database tables initialized');
}
+58
View File
@@ -0,0 +1,58 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { initializeDatabase } from './database';
import { authenticatePassword } from './middleware/auth';
import listRoutes from './routes/listRoutes';
import fieldRoutes from './routes/fieldRoutes';
import itemRoutes from './routes/itemRoutes';
import syncRoutes from './routes/syncRoutes';
import webRoutes from './routes/webRoutes';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Health check (no auth required)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// Apply authentication middleware to all API routes
app.use('/api', authenticatePassword);
// Routes (protected by auth)
app.use('/api/lists', listRoutes);
app.use('/api/fields', fieldRoutes);
app.use('/api/items', itemRoutes);
app.use('/api', syncRoutes);
// Web UI (no auth required, must be last to not interfere with API routes)
app.use('/', webRoutes);
// Initialize database and start server
try {
initializeDatabase();
console.log('Database synchronized successfully');
console.log('Database models loaded:');
console.log('- Lists');
console.log('- Fields');
console.log('- Items');
console.log('- ItemValues');
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
});
} catch (error) {
console.error('Database initialization error:', error);
process.exit(1);
}
export default app;
+48
View File
@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
let warningLogged = false;
export const authenticatePassword = (req: Request, res: Response, next: NextFunction) => {
const serverPassword = process.env.SERVER_PASSWORD;
// If no password is set in environment, skip authentication
if (!serverPassword) {
if (!warningLogged) {
console.warn('Warning: SERVER_PASSWORD not set in environment variables. Authentication is disabled.');
warningLogged = true;
}
return next();
}
// Get password from Authorization header (format: "Bearer <password>")
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: 'Unauthorized',
message: 'No authorization header provided'
});
}
// Parse the authorization header
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid authorization header format. Expected: Bearer <password>'
});
}
const providedPassword = parts[1];
// Validate password
if (providedPassword !== serverPassword) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid password'
});
}
// Password is valid, continue
next();
};
@@ -0,0 +1,75 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get fields for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId);
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
res.json(formattedFields);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch fields' });
}
});
// Create field
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0);
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id);
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create field' });
}
});
// Update field
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const { name, fieldType, fieldOptions, order } = req.body;
const result = db.prepare(`
UPDATE fields
SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ?
WHERE id = ?
`).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
}
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id);
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update field' });
}
});
// Delete field (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE fields
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
}
res.json({ message: 'Field deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete field' });
}
});
export default router;
+103
View File
@@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get items for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId);
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json(formattedItems);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch items' });
}
});
// Get item values
router.get('/:itemId/values', async (req: Request, res: Response) => {
try {
const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId);
res.json(values);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch item values' });
}
});
// Create item
router.post('/', async (req: Request, res: Response) => {
try {
const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create item' });
}
});
// Create/update item value
router.post('/values', async (req: Request, res: Response) => {
try {
const { id, itemId, fieldId, value, updatedAt } = req.body;
db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
value = ?,
updatedAt = ?
`).run(id, itemId, fieldId, value, updatedAt, value, updatedAt);
const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id);
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
}
});
// Update item
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id);
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update item' });
}
});
// Delete item (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
res.json({ message: 'Item deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete item' });
}
});
export default router;
@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get all lists
router.get('/', async (req: Request, res: Response) => {
try {
const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all();
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
res.json(formattedLists);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch lists' });
}
});
// Get single list
router.get('/:id', async (req: Request, res: Response) => {
try {
const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id);
if (!list) {
return res.status(404).json({ error: 'List not found' });
}
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch list' });
}
});
// Create list
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id);
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create list' });
}
});
// Update list
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET name = ?, updatedAt = ?
WHERE id = ?
`).run(req.body.name, updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id);
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update list' });
}
});
// Delete list (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
res.json({ message: 'List deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete list' });
}
});
export default router;
+229
View File
@@ -0,0 +1,229 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
interface SyncRequest {
lastSyncTimestamp: number;
lists: any[];
fields: any[];
items: any[];
itemValues: any[];
}
// Stats tracking
let syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
// Print stats every 30 seconds
setInterval(() => {
if (syncStats.totalSyncs > 0) {
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
console.log(`\n[STATS] Last ${elapsed}s: ${syncStats.totalSyncs} syncs`);
console.log(` Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
console.log(` Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values\n`);
// Reset stats
syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
}
}, 30000);
// Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() {
return {
upsertList: db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @createdAt, @updatedAt, @isDeleted)
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) => {
try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
// Track stats
syncStats.totalSyncs++;
const incomingLists = lists?.length || 0;
const incomingFields = fields?.length || 0;
const incomingItems = items?.length || 0;
const incomingValues = itemValues?.length || 0;
syncStats.listsReceived += incomingLists;
syncStats.fieldsReceived += incomingFields;
syncStats.itemsReceived += incomingItems;
syncStats.valuesReceived += incomingValues;
// Log only when there's actual data being synced
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
if (hasIncomingData) {
console.log(`[SYNC] Received: ${incomingLists} lists, ${incomingFields} fields, ${incomingItems} items, ${incomingValues} values`);
// Log list details
if (lists && lists.length > 0) {
lists.forEach(list => {
console.log(` List: ${list.id} - "${list.name}" (updated: ${list.updatedAt}, deleted: ${list.isDeleted})`);
});
}
}
// Save incoming data from client using a transaction
const transaction = db.transaction((data: SyncRequest) => {
// Get prepared statements lazily
const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements();
if (data.lists && data.lists.length > 0) {
for (const list of data.lists) {
upsertList.run({
id: list.id,
name: list.name,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0
});
console.log(` Saved list ${list.id}`);
}
}
if (data.fields && data.fields.length > 0) {
for (const field of data.fields) {
upsertField.run({
id: field.id,
name: field.name,
fieldType: field.fieldType,
fieldOptions: field.fieldOptions,
listId: field.listId,
order: field.order,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
isDeleted: field.isDeleted ? 1 : 0
});
}
}
if (data.items && data.items.length > 0) {
for (const item of data.items) {
upsertItem.run({
id: item.id,
listId: item.listId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDeleted: item.isDeleted ? 1 : 0
});
}
}
if (data.itemValues && data.itemValues.length > 0) {
for (const value of data.itemValues) {
upsertItemValue.run({
id: value.id,
itemId: value.itemId,
fieldId: value.fieldId,
value: value.value,
updatedAt: value.updatedAt
});
}
}
});
transaction({ lastSyncTimestamp, lists, fields, items, itemValues });
// Get updates from server since last sync
// If lastSyncTimestamp is 0, get all data (initial sync)
// Exclude deleted items from being sent
let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) {
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all();
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all();
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all();
serverItemValues = db.prepare('SELECT * FROM item_values').all();
} else {
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt > ?').all(lastSyncTimestamp);
}
// Convert isDeleted from INTEGER (0/1) to BOOLEAN
serverLists = (serverLists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
serverFields = (serverFields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
serverItems = (serverItems as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
const serverTimestamp = Date.now();
// Track outgoing stats
syncStats.listsSent += (serverLists as any[]).length;
syncStats.fieldsSent += (serverFields as any[]).length;
syncStats.itemsSent += (serverItems as any[]).length;
syncStats.valuesSent += (serverItemValues as any[]).length;
// Log only when sending data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData) {
console.log(`[SYNC] Sending: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
}
res.json({
lists: serverLists,
fields: serverFields,
items: serverItems,
itemValues: serverItemValues,
serverTimestamp
});
} catch (error) {
console.error('[SYNC] Error:', error);
res.status(500).json({ error: 'Sync failed' });
}
});
export default router;
+329
View File
@@ -0,0 +1,329 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Serve the web UI
router.get('/', (req: Request, res: Response) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CollabTable Server - Database Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.stats {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.stat-label {
color: #666;
font-size: 14px;
margin-bottom: 5px;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: bold;
}
.lists-container {
display: grid;
gap: 20px;
}
.list-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.list-name {
font-size: 20px;
font-weight: bold;
color: #333;
}
.list-id {
font-size: 12px;
color: #999;
font-family: monospace;
}
.fields-container {
margin-bottom: 15px;
}
.field-tag {
display: inline-block;
background: #e7f3ff;
color: #0066cc;
padding: 6px 12px;
border-radius: 4px;
margin-right: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.items-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
}
.items-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.no-data {
text-align: center;
color: #999;
padding: 40px;
font-style: italic;
}
.refresh-btn {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
}
.refresh-btn:hover {
background: #0056b3;
}
.deleted {
opacity: 0.5;
background: #fff3cd !important;
}
.badge {
display: inline-block;
background: #dc3545;
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
margin-left: 8px;
}
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>CollabTable Server - Database Viewer</h1>
<button class="refresh-btn" onclick="loadData()">Refresh</button>
</div>
<div class="stats">
<h2 style="margin-bottom: 10px;">Database Statistics</h2>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">Total Lists</div>
<div class="stat-value" id="total-lists">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Fields</div>
<div class="stat-value" id="total-fields">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Items</div>
<div class="stat-value" id="total-items">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Values</div>
<div class="stat-value" id="total-values">-</div>
</div>
</div>
</div>
<div id="lists-container" class="lists-container">
<div class="no-data">Loading...</div>
</div>
</div>
<script>
async function loadData() {
try {
const response = await fetch('/web/data');
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
const data = await response.json();
// Validate response structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format');
}
// Provide defaults for missing properties
const lists = data.lists || [];
const fields = data.fields || [];
const items = data.items || [];
const values = data.values || [];
const stats = data.stats || { lists: 0, fields: 0, items: 0, values: 0 };
// Update stats
document.getElementById('total-lists').textContent = stats.lists;
document.getElementById('total-fields').textContent = stats.fields;
document.getElementById('total-items').textContent = stats.items;
document.getElementById('total-values').textContent = stats.values;
// Render lists
const container = document.getElementById('lists-container');
if (lists.length === 0) {
container.innerHTML = '<div class="no-data">No lists found in database</div>';
return;
}
container.innerHTML = lists.map(list => {
const listFields = fields.filter(f => f.listId === list.id);
const listItems = items.filter(i => i.listId === list.id);
return \`
<div class="list-card \${list.isDeleted ? 'deleted' : ''}">
<div class="list-header">
<div>
<div class="list-name">
\${list.name}
\${list.isDeleted ? '<span class="badge">DELETED</span>' : ''}
</div>
<div class="list-id">ID: \${list.id}</div>
<div class="list-id">Created: \${new Date(list.createdAt).toLocaleString()}</div>
<div class="list-id">Updated: \${new Date(list.updatedAt).toLocaleString()}</div>
</div>
</div>
<div class="fields-container">
<strong>Fields (\${listFields.length}):</strong><br>
\${listFields.length > 0
? listFields.map(f => \`<span class="field-tag \${f.isDeleted ? 'deleted' : ''}">\${f.name} (\${f.fieldType})</span>\`).join('')
: '<span style="color: #999;">No fields</span>'
}
</div>
\${listItems.length > 0 ? \`
<table class="items-table">
<thead>
<tr>
<th>Item ID</th>
\${listFields.map(f => \`<th>\${f.name}</th>\`).join('')}
<th>Updated</th>
</tr>
</thead>
<tbody>
\${listItems.map(item => {
const itemValues = values.filter(v => v.itemId === item.id);
return \`
<tr class="\${item.isDeleted ? 'deleted' : ''}">
<td>\${item.id.substring(0, 8)}...</td>
\${listFields.map(field => {
const value = itemValues.find(v => v.fieldId === field.id);
return \`<td>\${value ? value.value : '-'}</td>\`;
}).join('')}
<td>\${new Date(item.updatedAt).toLocaleString()}</td>
</tr>
\`;
}).join('')}
</tbody>
</table>
\` : '<div style="color: #999; margin-top: 10px;">No items</div>'}
</div>
\`;
}).join('');
} catch (error) {
console.error('Error loading data:', error);
document.getElementById('lists-container').innerHTML =
'<div class="no-data" style="color: #dc3545;">Error loading data: ' + (error.message || 'Unknown error') + '</div>';
}
}
// Load data on page load
loadData();
// Auto-refresh every 5 seconds
setInterval(loadData, 5000);
</script>
</body>
</html>
`);
});
// API endpoint to get all data (no auth required for web UI)
router.get('/web/data', async (req: Request, res: Response) => {
try {
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
const values = db.prepare('SELECT * FROM item_values').all();
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json({
lists: formattedLists,
fields: formattedFields,
items: formattedItems,
values,
stats: {
lists: formattedLists.length,
fields: formattedFields.length,
items: formattedItems.length,
values: (values as any[]).length
}
});
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ error: 'Failed to fetch data' });
}
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}