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:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
@@ -0,0 +1,4 @@
|
||||
PORT=3000
|
||||
DB_PATH=/data/collabtable.db
|
||||
NODE_ENV=production
|
||||
SERVER_PASSWORD=your_secure_password_here
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Ensure data directory exists
|
||||
mkdir -p /data
|
||||
Generated
+2351
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user