v0.9.88 major change sqlite to postgres

This commit is contained in:
2026-03-20 10:46:29 -04:00
parent 7dc4cfcbce
commit ac7cba0f92
31 changed files with 3729 additions and 2645 deletions

View File

@@ -1,56 +1,30 @@
# ─────────────────────────────────────────────────────────────
# jama — Configuration
# just another messaging app
#
# Copy this file to .env and customize before first run.
# ─────────────────────────────────────────────────────────────
# ── Required ──────────────────────────────────────────────────────────────────
DB_PASSWORD=change_me_strong_password
JWT_SECRET=change_me_super_secret_jwt_key
# Project name — used as the Docker container name.
# If you run multiple jama instances on the same host, give each a unique name.
# ── App identity ──────────────────────────────────────────────────────────────
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.9.87
# App port — the host port Docker maps to the container
PORT=3000
# Timezone — must match your host timezone
# Run 'timedatectl' on Linux or 'ls /usr/share/zoneinfo' to find your value
# Examples: America/Toronto, Europe/London, Asia/Tokyo
TZ=UTC
# ── App ───────────────────────────────────────────────────────
# App name (can also be changed in the Settings UI after first run)
APP_NAME=jama
# Default public group name (created on first run only)
DEFCHAT_NAME=General Chat
# ── Admin credentials (used on FIRST RUN only) ────────────────
ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@jama.local
ADMIN_PASS=Admin@1234
# Default password for bulk-imported users (when no password is set in CSV)
USER_PASS=user@1234
# Set to true to reset the admin password to ADMIN_PASS on every restart.
# WARNING: Leave false in production — shows a warning banner on the login page when true.
ADMPW_RESET=false
# ── Security ──────────────────────────────────────────────────
# JWT secret — change this to a long random string in production!
# Generate one: openssl rand -hex 32
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production
# ── Database ──────────────────────────────────────────────────────────────────
DB_NAME=jama
DB_USER=jama
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
# Database encryption key (SQLCipher AES-256)
# Generate a strong key: openssl rand -hex 32
# Leave blank to run without encryption (not recommended for production).
#
# IMPORTANT — upgrading an existing unencrypted install:
# 1. docker compose down
# 2. Find your DB: docker volume inspect <project>_jama_db
# 3. node backend/scripts/encrypt-db.js --db /path/to/jama.db --key YOUR_KEY
# 4. Add DB_KEY=YOUR_KEY here, then: ./build.sh && docker compose up -d
DB_KEY=
# ── Tenancy mode ──────────────────────────────────────────────────────────────
# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM)
# host = multi-tenant (JAMA-HOST only)
APP_TYPE=selfhost
# ── JAMA-HOST only (ignored in selfhost mode) ─────────────────────────────────
# HOST_DOMAIN=jamachat.com
# HOST_ADMIN_KEY=change_me_host_admin_secret
# ── Optional ──────────────────────────────────────────────────────────────────
PORT=3000
TZ=UTC

86
Caddyfile.example Normal file
View File

@@ -0,0 +1,86 @@
# Caddyfile.example — JAMA-HOST reverse proxy
#
# Caddy handles SSL automatically via Let's Encrypt.
# Wildcard certs require a DNS challenge provider.
#
# Prerequisites:
# 1. Install the Caddy DNS plugin for your provider:
# https://caddyserver.com/docs/automatic-https#dns-challenge
# Common providers: cloudflare, route53, digitalocean
#
# 2. Set your DNS API token as an environment variable:
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
#
# 3. Add a wildcard DNS record in your DNS provider:
# *.jamachat.com → your server IP
# jamachat.com → your server IP
#
# Usage:
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
# Reload: caddy reload
# ── Wildcard subdomain ────────────────────────────────────────────────────────
# Handles team1.jamachat.com, teamB.jamachat.com, etc.
# Replace jamachat.com with your actual HOST_DOMAIN.
*.jamachat.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# Forward all requests to the jama app container
reverse_proxy localhost:3000
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# Logs (optional)
log {
output file /var/log/caddy/jama-access.log
format json
}
}
# ── Base domain (host admin panel) ───────────────────────────────────────────
jamachat.com {
reverse_proxy localhost:3000
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
-Server
}
}
# ── Custom tenant domains ─────────────────────────────────────────────────────
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
#
# 1. They add a DNS CNAME: chat.theircompany.com → jamachat.com
#
# 2. You add a block here and reload Caddy.
# Caddy will automatically obtain and renew the SSL cert.
#
# Example:
#
# chat.theircompany.com {
# reverse_proxy localhost:3000
# }
#
# Alternatively, use Caddy's on-demand TLS to handle custom domains
# automatically without editing this file:
#
# (on_demand_tls) {
# on_demand {
# ask http://localhost:3000/api/host/verify-domain
# }
# }
#
# *.jamachat.com, jamachat.com {
# tls { on_demand }
# reverse_proxy localhost:3000
# }

View File

@@ -2,47 +2,32 @@ ARG VERSION=dev
ARG BUILD_DATE=unknown
FROM node:20-alpine AS builder
WORKDIR /app
# Install frontend dependencies and build
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# Backend
FROM node:20-alpine
ARG VERSION=dev
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="jama" \
org.opencontainers.image.description="Self-hosted team chat PWA" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/yourorg/jama"
org.opencontainers.image.created="${BUILD_DATE}"
ENV JAMA_VERSION=${VERSION}
RUN apk add --no-cache sqlite python3 make g++ openssl-dev
# No native build tools needed — pg uses pure JS by default
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --omit=dev
# Remove build tools after compile to keep image lean
RUN apk del python3 make g++
COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public
# Create data and uploads directories
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
EXPOSE 3000
CMD ["node", "src/index.js"]

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.87",
"version": "0.10.1",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
@@ -19,8 +19,8 @@
"sharp": "^0.33.2",
"socket.io": "^4.6.1",
"web-push": "^3.6.7",
"better-sqlite3-multiple-ciphers": "^12.6.2",
"csv-parse": "^5.5.6"
"csv-parse": "^5.5.6",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.2"

View File

@@ -1,54 +1,53 @@
const express = require('express');
const http = require('http');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const path = require('path');
const jwt = require('jsonwebtoken');
const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db');
const cors = require('cors');
const path = require('path');
const jwt = require('jsonwebtoken');
const {
initDb, tenantMiddleware,
query, queryOne, queryResult, exec,
APP_TYPE, refreshTenantCache,
} = require('./models/db');
const { router: pushRouter, sendPushToUser } = require('./routes/push');
const { getLinkPreview } = require('./utils/linkPreview');
const app = express();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: '*', methods: ['GET', 'POST'] }
});
const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } });
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
const PORT = process.env.PORT || 3000;
const PORT = process.env.PORT || 3000;
// Init DB
initDb();
seedAdmin();
// Ensure Support group exists and all admins are members
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
const db = getDb();
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const a of admins) insert.run(supportGroupId, a.id);
}
// Middleware
// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors());
app.use(express.json());
app.use(cookieParser());
app.use(tenantMiddleware);
app.use('/uploads', express.static('/app/uploads'));
// API Routes
app.use('/api/auth', require('./routes/auth')(io));
app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(io));
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/auth', require('./routes/auth')(io));
app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(io));
app.use('/api/usergroups', require('./routes/usergroups')(io));
app.use('/api/schedule', require('./routes/schedule'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter);
app.use('/api/schedule', require('./routes/schedule'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter);
// Link preview proxy
// JAMA-HOST control plane — only registered when APP_TYPE=host
if (APP_TYPE === 'host') {
app.use('/api/host', require('./routes/host'));
console.log('[Server] JAMA-HOST control plane enabled at /api/host');
}
// ── Link preview proxy ────────────────────────────────────────────────────────
app.get('/api/link-preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL required' });
@@ -56,285 +55,296 @@ app.get('/api/link-preview', async (req, res) => {
res.json({ preview });
});
// Health check
// ── Health check ──────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ ok: true }));
// Dynamic manifest — must be before express.static so it takes precedence
app.get('/manifest.json', (req, res) => {
const db = getDb();
const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all();
const s = {};
for (const r of rows) s[r.key] = r.value;
// ── Dynamic PWA manifest ──────────────────────────────────────────────────────
app.get('/manifest.json', async (req, res) => {
try {
const rows = await query(req.schema,
"SELECT key, value FROM settings WHERE key IN ('app_name','logo_url','pwa_icon_192','pwa_icon_512')"
);
const s = {};
for (const r of rows) s[r.key] = r.value;
const appName = s.app_name || process.env.APP_NAME || 'jama';
const pwa192 = s.pwa_icon_192 || '';
const pwa512 = s.pwa_icon_512 || '';
const appName = s.app_name || process.env.APP_NAME || 'jama';
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
// Use uploaded+resized icons if they exist, else fall back to bundled PNGs.
// Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts.
const icon192 = pwa192 || '/icons/icon-192.png';
const icon512 = pwa512 || '/icons/icon-512.png';
const icons = [
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
];
const icons = [
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
];
const manifest = {
name: appName,
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
description: `${appName} - Team messaging`,
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait-primary',
background_color: '#ffffff',
theme_color: '#1a73e8',
icons,
};
res.setHeader('Content-Type', 'application/manifest+json');
res.setHeader('Cache-Control', 'no-cache');
res.json(manifest);
res.setHeader('Content-Type', 'application/manifest+json');
res.setHeader('Cache-Control', 'no-cache');
res.json({
name: appName,
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
description: `${appName} - Team messaging`,
start_url: '/', scope: '/', display: 'standalone',
orientation: 'portrait-primary',
background_color: '#ffffff', theme_color: '#1a73e8',
icons,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Serve frontend
// ── Frontend ──────────────────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, '../public')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Socket.io authentication
io.use((socket, next) => {
// ── Socket.io authentication ──────────────────────────────────────────────────
// Socket connections do not go through Express middleware, so we resolve
// schema from the handshake headers manually.
const { resolveSchema } = require('./models/db');
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Unauthorized'));
try {
const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb();
const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
// Resolve tenant schema from socket handshake headers
const schema = resolveSchema({ headers: socket.handshake.headers });
const user = await queryOne(schema,
'SELECT id, name, display_name, avatar, role, status FROM users WHERE id = $1 AND status = $2',
[decoded.id, 'active']
);
if (!user) return next(new Error('User not found'));
// Per-device enforcement: token must match an active session row
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
const session = await queryOne(schema,
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2',
[decoded.id, token]
);
if (!session) return next(new Error('Session displaced'));
socket.user = user;
socket.token = token;
socket.user = user;
socket.token = token;
socket.device = session.device;
socket.schema = schema;
next();
} catch (e) {
next(new Error('Invalid token'));
}
});
// Track online users: userId -> Set of socketIds
const onlineUsers = new Map();
// ── Online user tracking ──────────────────────────────────────────────────────
const onlineUsers = new Map(); // userId → Set<socketId>
io.on('connection', (socket) => {
io.on('connection', async (socket) => {
const userId = socket.user.id;
const schema = socket.schema;
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
onlineUsers.get(userId).add(socket.id);
// Record last_online timestamp
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
// Update last_online
exec(schema, 'UPDATE users SET last_online = NOW() WHERE id = $1', [userId]).catch(() => {});
// Broadcast online status
io.emit('user:online', { userId });
// Join personal room for direct notifications
socket.join(`user:${userId}`);
// Join rooms for all user's groups
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
for (const g of publicGroups) socket.join(`group:${g.id}`);
// Join socket rooms for all groups this user belongs to
try {
const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
for (const g of publicGroups) socket.join(`group:${g.id}`);
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
const privateGroups = await query(schema,
'SELECT group_id FROM group_members WHERE user_id = $1', [userId]
);
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
} catch (e) {
console.error('[Socket] Room join error:', e.message);
}
// When a new group is created and pushed to this socket, join its room
socket.on('group:join-room', ({ groupId }) => {
socket.join(`group:${groupId}`);
});
socket.on('group:join-room', ({ groupId }) => socket.join(`group:${groupId}`));
socket.on('group:leave-room', ({ groupId }) => socket.leave(`group:${groupId}`));
// When a user leaves a group, remove them from the socket room
socket.on('group:leave-room', ({ groupId }) => {
socket.leave(`group:${groupId}`);
});
// Handle new message
// ── New message ─────────────────────────────────────────────────────────────
socket.on('message:send', async (data) => {
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
const db = getDb();
try {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id = $1', [groupId]);
if (!group) return;
if (group.is_readonly && socket.user.role !== 'admin') return;
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
if (group.is_readonly && socket.user.role !== 'admin') return;
// Check access
if (group.type === 'private') {
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return;
}
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(groupId, userId, content || null, imageUrl || null, imageUrl ? 'image' : 'text', replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me,
rm.content as reply_content, rm.image_url as reply_image_url, rm.is_deleted as reply_is_deleted,
ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${groupId}`).emit('message:new', message);
// For private groups: push notify members who are offline
// (reuse `group` already fetched above)
if (group?.type === 'private') {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
for (const m of members) {
if (m.user_id === userId) continue; // don't notify sender
if (!onlineUsers.has(m.user_id)) {
// User is offline — send push
sendPushToUser(m.user_id, {
title: senderName,
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
url: '/',
groupId,
badge: 1,
}).catch(() => {});
} else {
// User is online but not necessarily in this group — send socket notification
const notif = { type: 'private_message', groupId, fromUser: socket.user };
for (const sid of onlineUsers.get(m.user_id)) {
io.to(sid).emit('notification:new', notif);
}
}
if (group.type === 'private') {
const member = await queryOne(schema,
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
[groupId, userId]
);
if (!member) return;
}
}
// Process @mentions — format is @[display name], look up user by display_name or name
if (content) {
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
for (const mentionName of mentionNames) {
const mentionedUser = db.prepare(
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
).get(mentionName, mentionName);
const matchId = mentionedUser?.id?.toString();
if (matchId && parseInt(matchId) !== userId) {
const notifResult = db.prepare(`
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
VALUES (?, 'mention', ?, ?, ?)
`).run(parseInt(matchId), result.lastInsertRowid, groupId, userId);
const mr = await queryResult(schema, `
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
`, [
groupId, userId,
content || null,
imageUrl || null,
imageUrl ? 'image' : 'text',
replyToId || null,
linkPreview ? JSON.stringify(linkPreview) : null,
]);
const msgId = mr.rows[0].id;
// Notify mentioned user — socket if online, push if not
const mentionedUserId = parseInt(matchId);
const notif = {
id: notifResult.lastInsertRowid,
type: 'mention',
groupId,
messageId: result.lastInsertRowid,
fromUser: socket.user,
};
if (onlineUsers.has(mentionedUserId)) {
for (const sid of onlineUsers.get(mentionedUserId)) {
io.to(sid).emit('notification:new', notif);
const message = await queryOne(schema, `
SELECT m.*,
u.name AS user_name, u.display_name AS user_display_name,
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me,
rm.content AS reply_content, rm.image_url AS reply_image_url,
rm.is_deleted AS reply_is_deleted,
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = $1
`, [msgId]);
message.reactions = [];
io.to(`group:${groupId}`).emit('message:new', message);
// Push notifications for private groups
if (group.type === 'private') {
const members = await query(schema,
'SELECT user_id FROM group_members WHERE group_id = $1', [groupId]
);
const senderName = socket.user.display_name || socket.user.name || 'Someone';
for (const m of members) {
if (m.user_id === userId) continue;
if (!onlineUsers.has(m.user_id)) {
sendPushToUser(m.user_id, {
title: senderName,
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
url: '/', groupId, badge: 1,
}).catch(() => {});
} else {
for (const sid of onlineUsers.get(m.user_id)) {
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
}
}
// Always send push (badge even when app is open)
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
sendPushToUser(mentionedUserId, {
}
}
// @mention notifications
if (content) {
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
for (const mentionName of mentionNames) {
const mentioned = await queryOne(schema,
"SELECT id FROM users WHERE status='active' AND (LOWER(display_name)=LOWER($1) OR LOWER(name)=LOWER($1))",
[mentionName]
);
if (!mentioned || mentioned.id === userId) continue;
const nr = await queryResult(schema,
"INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id) VALUES ($1,'mention',$2,$3,$4) RETURNING id",
[mentioned.id, msgId, groupId, userId]
);
const notif = { id: nr.rows[0].id, type: 'mention', groupId, messageId: msgId, fromUser: socket.user };
if (onlineUsers.has(mentioned.id)) {
for (const sid of onlineUsers.get(mentioned.id)) io.to(sid).emit('notification:new', notif);
}
const senderName = socket.user.display_name || socket.user.name || 'Someone';
sendPushToUser(mentioned.id, {
title: `${senderName} mentioned you`,
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
url: '/',
badge: 1,
url: '/', badge: 1,
}).catch(() => {});
}
}
} catch (e) {
console.error('[Socket] message:send error:', e.message);
}
});
// Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces
socket.on('reaction:toggle', (data) => {
const { messageId, emoji } = data;
const db = getDb();
const message = db.prepare('SELECT m.*, g.id as gid FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId);
if (!message) return;
// ── Reaction toggle ─────────────────────────────────────────────────────────
socket.on('reaction:toggle', async ({ messageId, emoji }) => {
try {
const message = await queryOne(schema,
'SELECT m.*, g.id AS gid FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1 AND m.is_deleted=FALSE',
[messageId]
);
if (!message) return;
// Find any existing reaction by this user on this message
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId);
const existing = await queryOne(schema,
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2',
[messageId, userId]
);
if (existing) {
if (existing.emoji === emoji) {
// Same emoji — toggle off (remove)
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
if (existing) {
if (existing.emoji === emoji) {
await exec(schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
} else {
await exec(schema, 'UPDATE reactions SET emoji=$1 WHERE id=$2', [emoji, existing.id]);
}
} else {
// Different emoji — replace
db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id);
await exec(schema,
'INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)',
[messageId, userId, emoji]
);
}
} else {
// No existing reaction — insert
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
const reactions = await query(schema, `
SELECT r.emoji, r.user_id, u.name AS user_name
FROM reactions r JOIN users u ON r.user_id=u.id
WHERE r.message_id=$1
`, [messageId]);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
} catch (e) {
console.error('[Socket] reaction:toggle error:', e.message);
}
const reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
});
// Handle message delete
socket.on('message:delete', (data) => {
const { messageId } = data;
const db = getDb();
const message = db.prepare(`
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
`).get(messageId);
if (!message) return;
// ── Message delete ──────────────────────────────────────────────────────────
socket.on('message:delete', async ({ messageId }) => {
try {
const message = await queryOne(schema, `
SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id, g.is_direct
FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1
`, [messageId]);
if (!message) return;
const isAdmin = socket.user.role === 'admin';
const isOwner = message.group_owner_id === userId;
const isAuthor = message.user_id === userId;
const isAdmin = socket.user.role === 'admin';
const isOwner = message.group_owner_id === userId;
const isAuthor = message.user_id === userId;
let canDelete = isAuthor || isOwner;
// Rules:
// 1. Author can always delete their own message
// 2. Admin can delete in any public group or any group they're a member of
// 3. Group owner can delete any message in their group
// 4. In direct messages: author + owner rules apply (no blanket block)
let canDelete = isAuthor || isOwner;
if (!canDelete && isAdmin) {
if (message.group_type === 'public') {
canDelete = true;
} else {
// Admin can delete in private/direct groups they're a member of
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
if (membership) canDelete = true;
if (!canDelete && isAdmin) {
if (message.group_type === 'public') {
canDelete = true;
} else {
const membership = await queryOne(schema,
'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2',
[message.group_id, userId]
);
if (membership) canDelete = true;
}
}
if (!canDelete) return;
await exec(schema,
'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1',
[messageId]
);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id });
} catch (e) {
console.error('[Socket] message:delete error:', e.message);
}
if (!canDelete) return;
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(messageId);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id });
});
// Handle typing
// ── Typing indicators ───────────────────────────────────────────────────────
socket.on('typing:start', ({ groupId }) => {
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
});
@@ -342,24 +352,38 @@ io.on('connection', (socket) => {
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
});
// Get online users
socket.on('users:online', () => {
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
});
// Handle disconnect
// ── Disconnect ──────────────────────────────────────────────────────────────
socket.on('disconnect', () => {
if (onlineUsers.has(userId)) {
onlineUsers.get(userId).delete(socket.id);
if (onlineUsers.get(userId).size === 0) {
onlineUsers.delete(userId);
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {});
io.emit('user:offline', { userId });
}
}
});
});
server.listen(PORT, () => {
console.log(`jama server running on port ${PORT}`);
// ── Start ─────────────────────────────────────────────────────────────────────
initDb().then(async () => {
if (APP_TYPE === 'host') {
try {
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
refreshTenantCache(tenants);
console.log(`[Server] Loaded ${tenants.length} tenant(s) into domain cache`);
} catch (e) {
console.warn('[Server] Could not load tenant cache:', e.message);
}
}
server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`));
}).catch(err => {
console.error('[Server] DB init failed:', err);
process.exit(1);
});
module.exports = { io };

View File

@@ -1,10 +1,8 @@
const jwt = require('jsonwebtoken');
const { getDb } = require('../models/db');
const jwt = require('jsonwebtoken');
const { query, queryOne, exec } = require('../models/db');
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
// Classify a User-Agent string into 'mobile' or 'desktop'.
// Tablets are treated as mobile (one shared slot).
function getDeviceClass(ua) {
if (!ua) return 'desktop';
const s = ua.toLowerCase();
@@ -13,24 +11,21 @@ function getDeviceClass(ua) {
return 'desktop';
}
function authMiddleware(req, res, next) {
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
const user = await queryOne(req.schema,
"SELECT * FROM users WHERE id = $1 AND status = 'active'", [decoded.id]
);
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
// Per-device enforcement: token must match an active session row
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
if (!session) {
return res.status(401).json({ error: 'Session expired. Please log in again.' });
}
req.user = user;
req.token = token;
const session = await queryOne(req.schema,
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2', [decoded.id, token]
);
if (!session) return res.status(401).json({ error: 'Session expired. Please log in again.' });
req.user = user;
req.token = token;
req.device = session.device;
next();
} catch (e) {
@@ -43,52 +38,57 @@ function adminMiddleware(req, res, next) {
next();
}
// Allows admins OR members of groups designated as Tool Managers
function teamManagerMiddleware(req, res, next) {
async function teamManagerMiddleware(req, res, next) {
if (req.user?.role === 'admin') return next();
const db = getDb();
// Prefer unified key, fall back to legacy keys for older installs
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
const allowedGroupIds = [
...new Set([
...JSON.parse(tmSetting?.value || '[]'),
...JSON.parse(gmSetting?.value || '[]'),
])
];
if (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
const member = db.prepare(`
SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${allowedGroupIds.map(() => '?').join(',')})
`).get(req.user.id, ...allowedGroupIds);
if (!member) return res.status(403).json({ error: 'Access denied' });
next();
try {
const tmSetting = await queryOne(req.schema,
"SELECT value FROM settings WHERE key = 'team_tool_managers'"
);
const gmSetting = await queryOne(req.schema,
"SELECT value FROM settings WHERE key = 'team_group_managers'"
);
const allowedGroupIds = [
...new Set([
...JSON.parse(tmSetting?.value || '[]'),
...JSON.parse(gmSetting?.value || '[]'),
])
];
if (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
const placeholders = allowedGroupIds.map((_, i) => `$${i + 2}`).join(',');
const member = await queryOne(req.schema,
`SELECT 1 FROM user_group_members WHERE user_id = $1 AND user_group_id IN (${placeholders})`,
[req.user.id, ...allowedGroupIds]
);
if (!member) return res.status(403).json({ error: 'Access denied' });
next();
} catch (e) {
res.status(500).json({ error: e.message });
}
}
function generateToken(userId) {
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
}
// Upsert the active session for this user+device class.
// Displaces any prior session on the same device class; the other device class is unaffected.
function setActiveSession(userId, token, userAgent) {
const db = getDb();
async function setActiveSession(schema, userId, token, userAgent) {
const device = getDeviceClass(userAgent);
db.prepare(`
await exec(schema, `
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, device) DO UPDATE SET token = ?, ua = ?, created_at = datetime('now')
`).run(userId, device, token, userAgent || null, token, userAgent || null);
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id, device) DO UPDATE SET token = $3, ua = $4, created_at = NOW()
`, [userId, device, token, userAgent || null]);
return device;
}
// Clear one device slot on logout, or all slots (no device arg) for suspend/delete
function clearActiveSession(userId, device) {
const db = getDb();
async function clearActiveSession(schema, userId, device) {
if (device) {
db.prepare('DELETE FROM active_sessions WHERE user_id = ? AND device = ?').run(userId, device);
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1 AND device = $2', [userId, device]);
} else {
db.prepare('DELETE FROM active_sessions WHERE user_id = ?').run(userId);
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1', [userId]);
}
}
module.exports = { authMiddleware, adminMiddleware, teamManagerMiddleware, generateToken, setActiveSession, clearActiveSession, getDeviceClass };
module.exports = {
authMiddleware, adminMiddleware, teamManagerMiddleware,
generateToken, setActiveSession, clearActiveSession, getDeviceClass,
};

View File

@@ -1,557 +1,384 @@
const Database = require('better-sqlite3-multiple-ciphers');
/**
* db.js — Postgres database layer for jama
*
* APP_TYPE environment variable controls tenancy:
* selfhost (default) → single schema 'public', one Postgres database
* host → one schema per tenant, derived from HTTP Host header
*
* All routes call: query(req.schema, sql, $params)
* req.schema is set by tenantMiddleware before any route handler runs.
*/
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
const DB_KEY = process.env.DB_KEY || '';
const APP_TYPE = process.env.APP_TYPE || 'selfhost';
let db;
// ── Connection pool ───────────────────────────────────────────────────────────
function getDb() {
if (!db) {
// Ensure the data directory exists before opening the DB
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`[DB] Created data directory: ${dir}`);
}
db = new Database(DB_PATH);
if (DB_KEY) {
// Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser
// Must be applied before any other DB access
const safeKey = DB_KEY.replace(/'/g, "''");
db.pragma(`cipher='sqlcipher'`);
db.pragma(`legacy=4`);
db.pragma(`key='${safeKey}'`);
console.log('[DB] Encryption key applied (SQLCipher4)');
} else {
console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted');
}
const journalMode = db.pragma('journal_mode = WAL', { simple: true });
if (journalMode !== 'wal') {
console.warn(`[DB] WARNING: journal_mode is '${journalMode}', expected 'wal' — performance may be degraded`);
}
db.pragma('synchronous = NORMAL'); // safe with WAL, faster than FULL
db.pragma('cache_size = -8000'); // 8MB page cache
db.pragma('foreign_keys = ON');
console.log(`[DB] Opened database at ${DB_PATH} (journal=${journalMode})`);
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'jama',
user: process.env.DB_USER || 'jama',
password: process.env.DB_PASSWORD || '',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err.message);
});
// ── Schema resolution ─────────────────────────────────────────────────────────
const tenantDomainCache = new Map();
function resolveSchema(req) {
if (APP_TYPE === 'selfhost') return 'public';
const host = (req.headers.host || '').toLowerCase().split(':')[0];
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
// Internal requests (Docker health checks, localhost) → public schema
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
// Subdomain: team1.jamachat.com → tenant_team1
if (host.endsWith(`.${baseDomain}`)) {
const slug = host.slice(0, -(baseDomain.length + 1));
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
return `tenant_${slug.replace(/[^a-z0-9]/g, '_')}`;
}
return db;
// Custom domain lookup (populated from host admin DB)
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host);
// Base domain → public schema (host admin panel)
if (host === baseDomain || host === `www.${baseDomain}`) return 'public';
throw new Error(`Unknown tenant for host: ${host}`);
}
function initDb() {
const db = getDb();
function refreshTenantCache(tenants) {
tenantDomainCache.clear();
for (const t of tenants) {
if (t.custom_domain) {
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
}
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 1,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag INTEGER NOT NULL DEFAULT 0,
allow_dm INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
// ── Schema name safety guard ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'public',
owner_id INTEGER,
is_default INTEGER NOT NULL DEFAULT 0,
is_readonly INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES users(id)
);
function assertSafeSchema(schema) {
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
throw new Error(`Unsafe schema name rejected: ${schema}`);
}
}
CREATE TABLE IF NOT EXISTS group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(group_id, user_id),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
// ── Core query helpers ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT,
type TEXT NOT NULL DEFAULT 'text',
image_url TEXT,
reply_to_id INTEGER,
is_deleted INTEGER NOT NULL DEFAULT 0,
link_preview TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (reply_to_id) REFERENCES messages(id)
);
async function query(schema, sql, params = []) {
assertSafeSchema(schema);
const client = await pool.connect();
try {
await client.query(`SET search_path TO "${schema}", public`);
const result = await client.query(sql, params);
return result.rows;
} finally {
client.release();
}
}
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(message_id, user_id, emoji),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
async function queryOne(schema, sql, params = []) {
const rows = await query(schema, sql, params);
return rows[0] || null;
}
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
async function queryResult(schema, sql, params = []) {
assertSafeSchema(schema);
const client = await pool.connect();
try {
await client.query(`SET search_path TO "${schema}", public`);
return await client.query(sql, params);
} finally {
client.release();
}
}
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
async function exec(schema, sql, params = []) {
await query(schema, sql, params);
}
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
async function withTransaction(schema, callback) {
assertSafeSchema(schema);
const client = await pool.connect();
try {
await client.query(`SET search_path TO "${schema}", public`);
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
// ── Migration runner ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
async function ensureSchema(schema) {
assertSafeSchema(schema);
// Use a direct client outside of search_path for schema creation
const client = await pool.connect();
try {
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
} finally {
client.release();
}
}
-- User groups (admin-managed, separate from chat groups)
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
);
async function runMigrations(schema) {
await ensureSchema(schema);
-- Members of user groups
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_group_id, user_id),
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Multi-group DMs: admin-created DMs whose members are user groups
CREATE TABLE IF NOT EXISTS multi_group_dms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER, -- paired private group in groups table
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
);
-- User groups that are members of a multi-group DM
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
multi_group_dm_id INTEGER NOT NULL,
user_group_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (multi_group_dm_id, user_group_id),
FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
await exec(schema, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Initialize default settings
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
insertSetting.run('app_name', process.env.APP_NAME || 'jama');
insertSetting.run('logo_url', '');
insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false');
insertSetting.run('icon_newchat', '');
insertSetting.run('icon_groupinfo', '');
insertSetting.run('pwa_icon_192', '');
insertSetting.run('pwa_icon_512', '');
insertSetting.run('color_title', '');
insertSetting.run('color_title_dark', '');
insertSetting.run('color_avatar_public', '');
insertSetting.run('color_avatar_dm', '');
insertSetting.run('registration_code', '');
insertSetting.run('feature_branding', 'false');
insertSetting.run('feature_group_manager', 'false');
insertSetting.run('feature_schedule_manager', 'false');
insertSetting.run('app_type', 'JAMA-Chat');
insertSetting.run('team_group_managers', '');
insertSetting.run('team_schedule_managers', '');
insertSetting.run('team_tool_managers', '');
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
const appliedSet = new Set(applied.map(r => r.version));
// Migration: add hide_admin_tag if upgrading from older version
try {
db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added hide_admin_tag column');
} catch (e) { /* column already exists */ }
const migrationsDir = path.join(__dirname, 'migrations');
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
// Migration: add allow_dm if upgrading from older version
try {
db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1");
console.log('[DB] Migration: added allow_dm column');
} catch (e) { /* column already exists */ }
for (const file of files) {
const m = file.match(/^(\d+)_/);
if (!m) continue;
const version = parseInt(m[1]);
if (appliedSet.has(version)) continue;
// Migration: replace single-session active_sessions with per-device version
try {
const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name);
if (!cols.includes('device')) {
db.exec("DROP TABLE IF EXISTS active_sessions");
db.exec(`
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('[DB] Migration: rebuilt active_sessions for per-device sessions');
}
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
// Migration: add is_direct for user-to-user direct messages
try {
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_direct column');
} catch (e) { /* column already exists */ }
// Migration: store both peer IDs so direct-message names survive member leave
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
console.log('[DB] Migration: added direct_peer1_id column');
} catch (e) { /* column already exists */ }
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
console.log('[DB] Migration: added direct_peer2_id column');
} catch (e) { /* column already exists */ }
// Migration: last_online timestamp per user
try {
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
console.log('[DB] Migration: added last_online column');
} catch (e) { /* column already exists */ }
// Migration: help_dismissed preference per user
try {
db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added help_dismissed column');
} catch (e) { /* column already exists */ }
// Migration: user-customised group display names (per-user, per-group)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
)
`);
console.log('[DB] Migration: user_group_names table ready');
} catch (e) { console.error('[DB] user_group_names migration error:', e.message); }
// Migration: pinned conversations (per-user, pins a group to top of sidebar)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS pinned_conversations (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
)
`);
console.log('[DB] Migration: pinned_conversations table ready');
} catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); }
// Migration: is_managed flag on groups (admin-managed DMs via Group Manager)
try {
db.exec("ALTER TABLE groups ADD COLUMN is_managed INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_managed column to groups');
} catch (e) { /* already exists */ }
// Migration: is_multi_group flag — distinguishes multi-group DMs from user-group DMs
try {
db.exec("ALTER TABLE groups ADD COLUMN is_multi_group INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_multi_group column to groups');
} catch (e) { /* already exists */ }
// Back-fill feature_schedule_manager for installs that registered before this setting existed
try {
const appType = db.prepare("SELECT value FROM settings WHERE key = 'app_type'").get();
if (appType && appType.value === 'JAMA-Team') {
db.prepare("INSERT INTO settings (key, value) VALUES ('feature_schedule_manager', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true' WHERE value = 'false'").run();
}
} catch(e) {}
// Back-fill is_multi_group for any existing multi-group DM groups
try {
db.exec("UPDATE groups SET is_multi_group = 1 WHERE id IN (SELECT dm_group_id FROM multi_group_dms WHERE dm_group_id IS NOT NULL)");
} catch (e) { /* ignore */ }
// Migration: user_groups and user_group_members tables
try {
db.exec(`
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_group_id, user_id),
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS multi_group_dms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
multi_group_dm_id INTEGER NOT NULL,
user_group_id INTEGER NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (multi_group_dm_id, user_group_id),
FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
)
`);
// Migration: add joined_at to user_group_members if missing
try { db.exec("ALTER TABLE user_group_members ADD COLUMN joined_at TEXT NOT NULL DEFAULT (datetime('now'))"); } catch(e) {}
console.log('[DB] Migration: user_groups tables ready');
} catch (e) { console.error('[DB] user_groups migration error:', e.message); }
// ── Schedule Manager ────────────────────────────────────────────────────────
try {
db.exec(`
CREATE TABLE IF NOT EXISTS event_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER,
default_duration_hrs REAL,
is_default INTEGER NOT NULL DEFAULT 0,
is_protected INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL
await withTransaction(schema, async (client) => {
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[version, file]
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
event_type_id INTEGER,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
location TEXT,
description TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
track_availability INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS event_user_groups (
event_id INTEGER NOT NULL,
user_group_id INTEGER NOT NULL,
PRIMARY KEY (event_id, user_group_id),
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS event_availability (
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (event_id, user_id),
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
// Migration: add columns if missing (must run before inserts)
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } catch(e) {}
// Delete the legacy "Default" type — "Event" is the canonical default
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
// Remove duplicates — keep the one with is_default=1
const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all();
if (evtTypes.length > 1) {
for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id);
}
// Ensure built-in types are correct
db.prepare("UPDATE event_types SET is_protected = 1, is_default = 1, default_duration_hrs = 1.0 WHERE name = 'Event'").run();
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run();
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").run();
console.log('[DB] Schedule Manager tables ready');
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
});
console.log('[DB] Schema initialized');
return db;
console.log(`[DB:${schema}] Migration ${version} done`);
}
}
function seedAdmin() {
const db = getDb();
// ── Seeding ───────────────────────────────────────────────────────────────────
// Strip any surrounding quotes from env vars (common docker-compose mistake)
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim();
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
async function seedSettings(schema) {
const defaults = [
['app_name', process.env.APP_NAME || 'jama'],
['logo_url', ''],
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
['icon_newchat', ''],
['icon_groupinfo', ''],
['pwa_icon_192', ''],
['pwa_icon_512', ''],
['color_title', ''],
['color_title_dark', ''],
['color_avatar_public', ''],
['color_avatar_dm', ''],
['registration_code', ''],
['feature_branding', 'false'],
['feature_group_manager', 'false'],
['feature_schedule_manager', 'false'],
['app_type', 'JAMA-Chat'],
['team_group_managers', ''],
['team_schedule_managers', ''],
['team_tool_managers', ''],
];
for (const [key, value] of defaults) {
await exec(schema,
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
[key, value]
);
}
}
async function seedEventTypes(schema) {
await exec(schema, `
INSERT INTO event_types (name, colour, is_default, is_protected, default_duration_hrs)
VALUES ('Event', '#6366f1', TRUE, TRUE, 1.0)
ON CONFLICT (name) DO UPDATE SET is_default=TRUE, is_protected=TRUE, default_duration_hrs=1.0
`);
await exec(schema,
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0) ON CONFLICT (name) DO NOTHING"
);
await exec(schema,
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0) ON CONFLICT (name) DO NOTHING"
);
}
async function seedAdmin(schema) {
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
const pwReset = process.env.ADMPW_RESET === 'true';
console.log(`[DB] Checking for default admin (${adminEmail})...`);
console.log(`[DB:${schema}] Checking for default admin (${adminEmail})...`);
const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get();
const existing = await queryOne(schema,
'SELECT * FROM users WHERE is_default_admin = TRUE'
);
if (!existing) {
try {
const hash = bcrypt.hashSync(adminPass, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
VALUES (?, ?, ?, 'admin', 'active', 1, 1)
`).run(adminName, adminEmail, hash);
const hash = bcrypt.hashSync(adminPass, 10);
const ur = await queryResult(schema, `
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
VALUES ($1, $2, $3, 'admin', 'active', TRUE, TRUE) RETURNING id
`, [adminName, adminEmail, hash]);
const adminId = ur.rows[0].id;
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
const chatName = strip(process.env.DEFCHAT_NAME) || 'General Chat';
const gr = await queryResult(schema,
"INSERT INTO groups (name, type, is_default, owner_id) VALUES ($1, 'public', TRUE, $2) RETURNING id",
[chatName, adminId]
);
await exec(schema,
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[gr.rows[0].id, adminId]
);
// Create default public group
const groupResult = db.prepare(`
INSERT INTO groups (name, type, is_default, owner_id)
VALUES (?, 'public', 1, ?)
`).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid);
const sr = await queryResult(schema,
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', $1, FALSE) RETURNING id",
[adminId]
);
await exec(schema,
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[sr.rows[0].id, adminId]
);
// Add admin to default group
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
.run(groupResult.lastInsertRowid, result.lastInsertRowid);
console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`);
seedSupportGroup();
} catch (err) {
console.error('[DB] ERROR creating default admin:', err.message);
}
console.log(`[DB:${schema}] Default admin + groups created`);
return;
}
console.log(`[DB] Default admin already exists (id=${existing.id})`);
// Handle ADMPW_RESET
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
if (pwReset) {
const hash = bcrypt.hashSync(adminPass, 10);
db.prepare(`
UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now')
WHERE is_default_admin = 1
`).run(hash);
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
console.log('[DB] Admin password reset via ADMPW_RESET=true');
await exec(schema,
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
[hash]
);
await exec(schema, "UPDATE settings SET value='true', updated_at=NOW() WHERE key='pw_reset_active'");
console.log(`[DB:${schema}] Admin password reset`);
} else {
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
await exec(schema, "UPDATE settings SET value='false', updated_at=NOW() WHERE key='pw_reset_active'");
}
}
function addUserToPublicGroups(userId) {
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const g of publicGroups) {
insert.run(g.id, userId);
// ── Main init (called on server startup) ─────────────────────────────────────
async function initDb() {
// Wait for Postgres to be ready (up to 30s)
for (let i = 0; i < 30; i++) {
try {
await pool.query('SELECT 1');
console.log('[DB] Connected to Postgres');
break;
} catch (e) {
console.log(`[DB] Waiting for Postgres... (${i + 1}/30)`);
await new Promise(r => setTimeout(r, 1000));
}
}
await runMigrations('public');
await seedSettings('public');
await seedEventTypes('public');
await seedAdmin('public');
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
if (APP_TYPE === 'host') {
const hostPlan = [
['app_type', 'JAMA-Team'],
['feature_branding', 'true'],
['feature_group_manager', 'true'],
['feature_schedule_manager', 'true'],
];
for (const [key, value] of hostPlan) {
await exec('public',
'INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()',
[key, value]
);
}
console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan');
}
console.log('[DB] Initialisation complete');
}
// ── Helper functions used by routes ──────────────────────────────────────────
async function addUserToPublicGroups(schema, userId) {
const groups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
for (const g of groups) {
await exec(schema,
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[g.id, userId]
);
}
}
function seedSupportGroup() {
const db = getDb();
async function getOrCreateSupportGroup(schema) {
const g = await queryOne(schema, "SELECT id FROM groups WHERE name='Support' AND type='private'");
if (g) return g.id;
// Create the Support group if it doesn't exist
const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
if (existing) return existing.id;
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
const admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
if (!admin) return null;
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_default)
VALUES ('Support', 'private', ?, 0)
`).run(admin.id);
const groupId = result.lastInsertRowid;
// Add all current admins to the Support group
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const a of admins) insert.run(groupId, a.id);
console.log('[DB] Support group created');
const r = await queryResult(schema,
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support','private',$1,FALSE) RETURNING id",
[admin.id]
);
const groupId = r.rows[0].id;
const admins = await query(schema, "SELECT id FROM users WHERE role='admin' AND status='active'");
for (const a of admins) {
await exec(schema,
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[groupId, a.id]
);
}
return groupId;
}
function getOrCreateSupportGroup() {
const db = getDb();
const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
if (group) return group.id;
return seedSupportGroup();
// ── Tenant middleware ─────────────────────────────────────────────────────────
function tenantMiddleware(req, res, next) {
try {
req.schema = resolveSchema(req);
next();
} catch (err) {
console.error('[Tenant]', err.message);
res.status(404).json({ error: 'Unknown tenant' });
}
}
module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };
module.exports = {
query, queryOne, queryResult, exec, withTransaction,
initDb, runMigrations, ensureSchema,
tenantMiddleware, resolveSchema, refreshTenantCache,
APP_TYPE, pool,
addUserToPublicGroups, getOrCreateSupportGroup,
seedSettings, seedEventTypes, seedAdmin,
};

View File

@@ -0,0 +1,213 @@
-- Migration 001: Initial schema
-- Converts all SQLite tables to Postgres-native types.
-- TIMESTAMPTZ replaces TEXT for dates.
-- SERIAL replaces AUTOINCREMENT.
-- Constraints use Postgres syntax throughout.
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
is_default_admin BOOLEAN NOT NULL DEFAULT FALSE,
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
allow_dm BOOLEAN NOT NULL DEFAULT TRUE,
last_online TIMESTAMPTZ,
help_dismissed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'public',
owner_id INTEGER REFERENCES users(id),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
direct_peer1_id INTEGER,
direct_peer2_id INTEGER,
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
is_multi_group BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS group_members (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(group_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
type TEXT NOT NULL DEFAULT 'text',
image_url TEXT,
reply_to_id INTEGER REFERENCES messages(id),
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
link_preview TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reactions (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, device)
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, device)
);
CREATE TABLE IF NOT EXISTS user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS pinned_conversations (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS user_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_group_id, user_id)
);
CREATE TABLE IF NOT EXISTS multi_group_dms (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
multi_group_dm_id INTEGER NOT NULL REFERENCES multi_group_dms(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (multi_group_dm_id, user_group_id)
);
-- ── Schedule Manager ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS event_types (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER REFERENCES user_groups(id) ON DELETE SET NULL,
default_duration_hrs NUMERIC,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_protected BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
event_type_id INTEGER REFERENCES event_types(id) ON DELETE SET NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
location TEXT,
description TEXT,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
track_availability BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule JSONB,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS event_user_groups (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
PRIMARY KEY (event_id, user_group_id)
);
CREATE TABLE IF NOT EXISTS event_availability (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, user_id)
);
-- ── Indexes for common query patterns ────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_messages_group_id ON messages(group_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at);
CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);

View File

@@ -0,0 +1,96 @@
-- Migration 002: updated_at auto-trigger + additional indexes
--
-- Adds a reusable Postgres trigger function that automatically sets
-- updated_at = NOW() on any UPDATE, eliminating the need to set it
-- manually in every route. Also adds a few missing indexes.
-- ── Auto-updated_at trigger function ─────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to all tables that have an updated_at column
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated_at') THEN
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_groups_updated_at') THEN
CREATE TRIGGER trg_groups_updated_at
BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_settings_updated_at') THEN
CREATE TRIGGER trg_settings_updated_at
BEFORE UPDATE ON settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_user_groups_updated_at') THEN
CREATE TRIGGER trg_user_groups_updated_at
BEFORE UPDATE ON user_groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_multi_group_dms_updated_at') THEN
CREATE TRIGGER trg_multi_group_dms_updated_at
BEFORE UPDATE ON multi_group_dms
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_updated_at') THEN
CREATE TRIGGER trg_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ── Additional indexes ────────────────────────────────────────────────────────
-- Notifications: most queries filter by user + read status
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON notifications(user_id, is_read)
WHERE is_read = FALSE;
-- Sessions: lookup by user is common on logout / session cleanup
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
ON sessions(user_id);
-- Active sessions: covered by PK (user_id, device) but explicit for clarity
CREATE INDEX IF NOT EXISTS idx_active_sessions_token
ON active_sessions(token);
-- Push subscriptions: lookup by user is the hot path
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user
ON push_subscriptions(user_id);
-- User group members: reverse lookup (which groups is a user in?)
CREATE INDEX IF NOT EXISTS idx_user_group_members_user
ON user_group_members(user_id);
-- Event availability: reverse lookup (which events has a user responded to?)
CREATE INDEX IF NOT EXISTS idx_event_availability_user
ON event_availability(user_id);
-- Events: filter by created_by (schedule manager views)
CREATE INDEX IF NOT EXISTS idx_events_type
ON events(event_type_id);

View File

@@ -0,0 +1,31 @@
-- Migration 003: Tenant registry (JAMA-HOST mode)
--
-- This table lives in the 'public' schema and is the source of truth for
-- all tenants in host mode. In selfhost mode this table exists but stays
-- empty — it has no effect on anything.
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, -- used as schema name: tenant_{slug}
name TEXT NOT NULL, -- display name
schema_name TEXT NOT NULL UNIQUE, -- actual Postgres schema: tenant_{slug}
custom_domain TEXT, -- optional: team1.example.com
plan TEXT NOT NULL DEFAULT 'chat', -- chat | brand | team
status TEXT NOT NULL DEFAULT 'active', -- active | suspended
admin_email TEXT, -- first admin email for this tenant
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
CREATE INDEX IF NOT EXISTS idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
-- Auto-update updated_at
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_tenants_updated_at') THEN
CREATE TRIGGER trg_tenants_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;

View File

@@ -0,0 +1,6 @@
-- Migration 004: Host plan feature flags placeholder
--
-- Feature flag enforcement for APP_TYPE=host is handled in db.js initDb()
-- which runs on every startup and upserts the correct values.
-- This migration exists as a version marker — no SQL changes needed.
SELECT 1;

View File

@@ -0,0 +1,101 @@
# jama Migration Guide
## How migrations work
jama uses a simple file-based migration system. On every startup, `db.js` reads
all `.sql` files in this directory, sorted by version number, and applies any
that haven't been recorded in the `schema_migrations` table.
Migrations run inside a transaction — if anything fails, the whole migration
rolls back and the version is not recorded, so startup will retry it next time.
---
## Adding a new migration
1. Create a new file in this directory named `NNN_description.sql` where `NNN`
is the next sequential number (zero-padded to 3 digits):
```
001_initial_schema.sql ← already applied
002_add_user_preferences.sql
003_add_tenant_table.sql
```
2. Write standard Postgres SQL. Use `IF NOT EXISTS` / `IF EXISTS` guards where
possible so migrations are safe to replay:
```sql
-- Add a new column
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
-- Add a new table
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key)
);
-- Add an index
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
```
3. Deploy. On next startup jama will automatically detect and apply the new
migration, logging:
```
[DB:public] Applying migration 2: 002_add_user_preferences.sql
[DB:public] Migration 2 done
```
---
## Rules
- **Never edit an applied migration.** Once `001_initial_schema.sql` has been
applied to any database, it must not change. Add a new numbered file instead.
- **Always use `IF NOT EXISTS` / `IF EXISTS`.** This makes migrations safe to
run against schemas that may be partially applied (e.g. after a failed deploy).
- **One logical change per file.** Easier to reason about and roll back mentally.
- **No data mutations in migrations unless unavoidable.** Seed data lives in
`db.js` (`seedSettings`, `seedEventTypes`, `seedAdmin`). Migrations are for
schema structure only.
- **JAMA-HOST:** When a new tenant is provisioned, `runMigrations(schema)` is
called on their fresh schema — they get all migrations from `001` onward
applied at creation time. Existing tenants get new migrations on the next
startup automatically.
---
## Checking migration status
```bash
# Connect to the running Postgres container
docker compose exec db psql -U jama -d jama
# See which migrations have been applied
SELECT * FROM schema_migrations ORDER BY version;
# In host mode, check a specific tenant schema
SET search_path TO tenant_teamname;
SELECT * FROM schema_migrations ORDER BY version;
```
---
## Emergency rollback
Migrations do not include automatic down/rollback scripts. If a migration causes
problems in production:
1. Stop the app container: `docker compose stop jama`
2. Connect to Postgres and manually reverse the change
3. Delete the migration record: `DELETE FROM schema_migrations WHERE version = NNN;`
4. Fix the migration file
5. Restart: `docker compose start jama`

View File

@@ -5,7 +5,7 @@ const fs = require('fs');
const ABOUT_FILE = '/app/data/about.json';
const DEFAULTS = {
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
built_with: 'Node.js · Express · Socket.io · PostgreSQL · React · Vite · Claude.ai',
developer: 'Ricky Stretch',
license: 'AGPL 3.0',
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',

View File

@@ -1,130 +1,100 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { getDb, getOrCreateSupportGroup } = require('../models/db');
const bcrypt = require('bcryptjs');
const { query, queryOne, queryResult, exec, getOrCreateSupportGroup } = require('../models/db');
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
module.exports = function(io) {
const router = express.Router();
const router = express.Router();
// Login
router.post('/login', (req, res) => {
const { email, password, rememberMe } = req.body;
const db = getDb();
// Login
router.post('/login', async (req, res) => {
const { email, password, rememberMe } = req.body;
try {
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.status === 'suspended') {
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
return res.status(403).json({ error: 'suspended', adminEmail: admin?.email });
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
if (user.status === 'suspended') {
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
return res.status(403).json({
error: 'suspended',
adminEmail: adminUser?.email
});
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
if (!bcrypt.compareSync(password, user.password))
return res.status(401).json({ error: 'Invalid credentials' });
const valid = bcrypt.compareSync(password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user.id);
const ua = req.headers['user-agent'] || '';
const device = await setActiveSession(req.schema, user.id, token, ua);
if (io) io.to(`user:${user.id}`).emit('session:displaced', { device });
const token = generateToken(user.id);
const ua = req.headers['user-agent'] || '';
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
// Kick any live socket on the same device class — it now holds a stale token
if (io) {
io.to(`user:${user.id}`).emit('session:displaced', { device });
}
const { password: _, ...userSafe } = user;
res.json({
token,
user: userSafe,
mustChangePassword: !!user.must_change_password,
rememberMe: !!rememberMe
const { password: _, ...userSafe } = user;
res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe });
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
// Change password
router.post('/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
// Change password
router.post('/change-password', authMiddleware, async (req, res) => {
const { currentPassword, newPassword } = req.body;
try {
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE id = $1', [req.user.id]);
if (!bcrypt.compareSync(currentPassword, user.password))
return res.status(400).json({ error: 'Current password is incorrect' });
if (newPassword.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' });
const hash = bcrypt.hashSync(newPassword, 10);
await exec(req.schema,
'UPDATE users SET password = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2',
[hash, req.user.id]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
if (!bcrypt.compareSync(currentPassword, user.password)) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
// Logout
router.post('/logout', authMiddleware, async (req, res) => {
try {
await clearActiveSession(req.schema, req.user.id, req.device);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
res.json({ success: true });
});
// Support contact form
router.post('/support', async (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim())
return res.status(400).json({ error: 'All fields are required' });
if (message.trim().length > 2000)
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
try {
const groupId = await getOrCreateSupportGroup(req.schema);
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
const admin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Logout — clear active session for this device class only
router.post('/logout', authMiddleware, (req, res) => {
clearActiveSession(req.user.id, req.device);
res.json({ success: true });
});
const content = `📬 **Support Request**\n**Name:** ${name.trim()}\n**Email:** ${email.trim()}\n\n${message.trim()}`;
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id, user_id, content, type) VALUES ($1,$2,$3,'text') RETURNING id",
[groupId, admin.id, content]
);
const newMsg = await queryOne(req.schema, `
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
`, [mr.rows[0].id]);
if (newMsg) { newMsg.reactions = []; io.to(`group:${groupId}`).emit('message:new', newMsg); }
// Public support contact form — no auth required
router.post('/support', (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim()) {
return res.status(400).json({ error: 'All fields are required' });
}
if (message.trim().length > 2000) {
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
}
const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'");
for (const a of admins) io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
const db = getDb();
// Get or create the Support group
const groupId = getOrCreateSupportGroup();
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Find a system/admin user to post as (default admin)
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Format the support message
const content = `📬 **Support Request**
**Name:** ${name.trim()}
**Email:** ${email.trim()}
${message.trim()}`;
const msgResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'text')
`).run(groupId, admin.id, content);
// Emit socket event so online admins see the message immediately
const newMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(msgResult.lastInsertRowid);
if (newMsg) {
newMsg.reactions = [];
io.to(`group:${groupId}`).emit('message:new', newMsg);
}
// Notify each admin via their user channel so they can reload groups if needed
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
for (const a of admins) {
io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
}
console.log(`[Support] Message from ${email} posted to Support group`);
res.json({ success: true });
});
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
};

View File

@@ -1,449 +1,317 @@
const express = require('express');
const fs = require('fs');
const router = express.Router();
const { getDb } = require('../models/db');
const fs = require('fs');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Helper: emit group:new to all members of a group
function emitGroupNew(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
catch (e) { console.warn('[Groups] Could not delete image:', e.message); }
}
module.exports = (io) => {
async function emitGroupNew(schema, io, groupId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return;
if (group.type === 'public') {
io.emit('group:new', { group });
} else {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
for (const m of members) {
io.to(`user:${m.user_id}`).emit('group:new', { group });
}
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
for (const m of members) io.to(`user:${m.user_id}`).emit('group:new', { group });
}
}
// Delete an uploaded image file from disk
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try {
const filePath = '/app' + imageUrl;
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (e) {
console.warn('[Groups] Could not delete image file:', e.message);
}
}
// Helper: emit group:deleted to all members
function emitGroupDeleted(io, groupId, members) {
for (const uid of members) {
io.to(`user:${uid}`).emit('group:deleted', { groupId });
}
}
// Helper: emit group:updated to all members
function emitGroupUpdated(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
async function emitGroupUpdated(schema, io, groupId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return;
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
const uids = group.type === 'public'
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
: members;
for (const m of uids) {
io.to(`user:${m.user_id}`).emit('group:updated', { group });
let uids;
if (group.type === 'public') {
uids = await query(schema, "SELECT id AS user_id FROM users WHERE status='active'");
} else {
uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
}
for (const m of uids) io.to(`user:${m.user_id}`).emit('group:updated', { group });
}
// Inject io into routes
module.exports = (io) => {
// Get all groups for current user
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const userId = req.user.id;
const publicGroups = db.prepare(`
SELECT g.*,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at,
(SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id
FROM groups g
WHERE g.type = 'public'
ORDER BY g.is_default DESC, g.name ASC
`).all();
// For direct messages, replace name with opposite user's display name
const privateGroupsRaw = db.prepare(`
SELECT g.*,
u.name as owner_name,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at,
(SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id
FROM groups g
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
LEFT JOIN users u ON g.owner_id = u.id
WHERE g.type = 'private'
ORDER BY last_message_at DESC NULLS LAST
`).all(userId);
// For direct groups, set the name to the other user's display name
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
const privateGroups = privateGroupsRaw.map(g => {
if (g.is_direct) {
// Backfill peer IDs for groups created before this migration
if (!g.direct_peer1_id || !g.direct_peer2_id) {
const peers = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 2').all(g.id);
if (peers.length === 2) {
db.prepare('UPDATE groups SET direct_peer1_id = ?, direct_peer2_id = ? WHERE id = ?')
.run(peers[0].user_id, peers[1].user_id, g.id);
g.direct_peer1_id = peers[0].user_id;
g.direct_peer2_id = peers[1].user_id;
}
}
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
if (otherUserId) {
const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId);
if (other) {
g.peer_id = otherUserId;
g.peer_real_name = other.name;
g.peer_display_name = other.display_name || null; // null if no custom display name set
g.peer_avatar = other.avatar || null;
g.name = other.display_name || other.name;
}
}
}
// Apply user's custom group name if set
const custom = db.prepare('SELECT name FROM user_group_names WHERE user_id = ? AND group_id = ?').get(userId, g.id);
if (custom) {
g.owner_name_original = g.name; // original name shown in brackets in GroupInfoModal
g.name = custom.name;
}
return g;
});
res.json({ publicGroups, privateGroups });
});
// Create group
router.post('/', authMiddleware, (req, res) => {
const { name, type, memberIds, isReadonly, isDirect } = req.body;
const db = getDb();
if (type === 'public' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create public groups' });
}
// Direct message: find or create
if (isDirect && memberIds && memberIds.length === 1) {
const otherUserId = memberIds[0];
// GET all groups for current user
router.get('/', authMiddleware, async (req, res) => {
try {
const userId = req.user.id;
const publicGroups = await query(req.schema, `
SELECT g.*,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id
FROM groups g WHERE g.type='public' ORDER BY g.is_default DESC, g.name ASC
`);
// Check if a direct group already exists between these two users
const existing = db.prepare(`
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ?
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
WHERE g.is_direct = 1
LIMIT 1
`).get(userId, otherUserId);
const privateGroupsRaw = await query(req.schema, `
SELECT g.*, u.name AS owner_name,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
ORDER BY last_message_at DESC NULLS LAST
`, [userId]);
if (existing) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
// Ensure current user is still a member (may have left)
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
// Re-set readonly to false so both can post again
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) });
const privateGroups = await Promise.all(privateGroupsRaw.map(async g => {
if (g.is_direct) {
if (!g.direct_peer1_id || !g.direct_peer2_id) {
const peers = await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 2', [g.id]);
if (peers.length === 2) {
await exec(req.schema, 'UPDATE groups SET direct_peer1_id=$1, direct_peer2_id=$2 WHERE id=$3', [peers[0].user_id, peers[1].user_id, g.id]);
g.direct_peer1_id = peers[0].user_id; g.direct_peer2_id = peers[1].user_id;
}
}
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
if (otherUserId) {
const other = await queryOne(req.schema, 'SELECT display_name, name, avatar FROM users WHERE id=$1', [otherUserId]);
if (other) {
g.peer_id = otherUserId; g.peer_real_name = other.name;
g.peer_display_name = other.display_name || null; g.peer_avatar = other.avatar || null;
g.name = other.display_name || other.name;
}
}
}
const custom = await queryOne(req.schema, 'SELECT name FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, g.id]);
if (custom) { g.owner_name_original = g.name; g.name = custom.name; }
return g;
}));
res.json({ publicGroups, privateGroups });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST create group
router.post('/', authMiddleware, async (req, res) => {
const { name, type, memberIds, isReadonly, isDirect } = req.body;
try {
if (type === 'public' && req.user.role !== 'admin')
return res.status(403).json({ error: 'Only admins can create public groups' });
// Direct message
if (isDirect && memberIds?.length === 1) {
const otherUserId = memberIds[0], userId = req.user.id;
const existing = await queryOne(req.schema, `
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id=g.id AND gm1.user_id=$1
JOIN group_members gm2 ON gm2.group_id=g.id AND gm2.user_id=$2
WHERE g.is_direct=TRUE LIMIT 1
`, [userId, otherUserId]);
if (existing) {
await exec(req.schema, "UPDATE groups SET is_readonly=FALSE, owner_id=NULL, updated_at=NOW() WHERE id=$1", [existing.id]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [existing.id, userId]);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [existing.id]) });
}
const otherUser = await queryOne(req.schema, 'SELECT name, display_name FROM users WHERE id=$1', [otherUserId]);
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
const r = await queryResult(req.schema,
"INSERT INTO groups (name,type,owner_id,is_readonly,is_direct,direct_peer1_id,direct_peer2_id) VALUES ($1,'private',NULL,FALSE,TRUE,$2,$3) RETURNING id",
[dmName, userId, otherUserId]
);
const groupId = r.rows[0].id;
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
await emitGroupNew(req.schema, io, groupId);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
}
// Get other user's display name for the group name (stored internally, overridden per-user on fetch)
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
VALUES (?, 'private', NULL, 0, 1, ?, ?)
`).run(dmName, userId, otherUserId);
const groupId = result.lastInsertRowid;
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId);
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify both users via socket
emitGroupNew(io, groupId);
return res.json({ group });
}
// For private groups: check if exact same set of members already exists in a group
if ((type === 'private' || !type) && !isDirect && memberIds && memberIds.length > 0) {
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a, b) => a - b);
const count = allMemberIds.length;
// Find all private non-direct groups where the creator is a member
const candidates = db.prepare(`
SELECT g.id FROM groups g
JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ?
WHERE g.type = 'private' AND g.is_direct = 0
`).all(req.user.id);
for (const candidate of candidates) {
const members = db.prepare(
'SELECT user_id FROM group_members WHERE group_id = ? ORDER BY user_id'
).all(candidate.id).map(r => r.user_id);
if (members.length === count &&
members.every((id, i) => id === allMemberIds[i])) {
// Exact duplicate found — return the existing group
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(candidate.id);
return res.json({ group, duplicate: true });
// Check for duplicate private group
if ((type === 'private' || !type) && !isDirect && memberIds?.length > 0) {
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a,b) => a-b);
const candidates = await query(req.schema,
'SELECT g.id FROM groups g JOIN group_members gm ON gm.group_id=g.id AND gm.user_id=$1 WHERE g.type=\'private\' AND g.is_direct=FALSE',
[req.user.id]
);
for (const c of candidates) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 ORDER BY user_id', [c.id])).map(r => r.user_id);
if (members.length === allMemberIds.length && members.every((id,i) => id === allMemberIds[i]))
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [c.id]), duplicate: true });
}
}
}
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
VALUES (?, ?, ?, ?, 0)
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
const groupId = result.lastInsertRowid;
if (type === 'public') {
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const u of allUsers) insert.run(groupId, u.id);
} else {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
if (memberIds && memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const uid of memberIds) insert.run(groupId, uid);
const r = await queryResult(req.schema,
'INSERT INTO groups (name,type,owner_id,is_readonly,is_direct) VALUES ($1,$2,$3,$4,FALSE) RETURNING id',
[name, type||'private', req.user.id, !!isReadonly]
);
const groupId = r.rows[0].id;
if (type === 'public') {
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
} else {
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, req.user.id]);
if (memberIds?.length > 0) for (const uid of memberIds) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
}
}
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify all members via socket
emitGroupNew(io, groupId);
res.json({ group });
await emitGroupNew(req.schema, io, groupId);
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Rename group
router.patch('/:id/rename', authMiddleware, (req, res) => {
// PATCH rename
router.patch('/:id/rename', authMiddleware, async (req, res) => {
const { name } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can rename private group' });
}
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
emitGroupUpdated(io, group.id);
res.json({ success: true });
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can rename' });
await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name, group.id]);
await emitGroupUpdated(req.schema, io, group.id);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get group members
router.get('/:id/members', authMiddleware, (req, res) => {
const db = getDb();
const members = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
FROM group_members gm
JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = ?
ORDER BY u.name ASC
`).all(req.params.id);
res.json({ members });
// GET members
router.get('/:id/members', authMiddleware, async (req, res) => {
try {
const members = await query(req.schema,
'SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status FROM group_members gm JOIN users u ON gm.user_id=u.id WHERE gm.group_id=$1 ORDER BY u.name ASC',
[req.params.id]
);
res.json({ members });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Add member to private group
router.post('/:id/members', authMiddleware, (req, res) => {
// POST add member
router.post('/:id/members', authMiddleware, async (req, res) => {
const { userId } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can add members' });
}
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
// Post a system message so all members see who was added
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${addedName} has joined the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
// Join all of the added user's active sockets to the group room server-side,
// so they receive messages immediately without needing a client round-trip
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
// Notify the added user in real-time so their sidebar updates without a refresh
io.to(`user:${userId}`).emit('group:new', { group });
res.json({ success: true });
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' });
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]);
const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, userId, `${addedName} has joined the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
io.to(`user:${userId}`).emit('group:new', { group });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Remove a member from a private group
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner or admin can remove members' });
}
const targetId = parseInt(req.params.userId);
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
const removedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(targetId);
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
// Post system message so remaining members see the removal notice
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, targetId, `${removedName} has been removed from the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
// Remove the user from the socket room and update their sidebar
io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`);
io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
// DELETE remove member
router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
const targetId = parseInt(req.params.userId);
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, targetId, `${removedName} has been removed from the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`);
io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Leave private group
router.delete('/:id/leave', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator. Contact an admin to be removed.' });
const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
// Post a system message so remaining members see the leave notice
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${leaverName} has left the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
// Broadcast to remaining members in the group room
io.to(`group:${group.id}`).emit('message:new', sysMsg);
// Always remove leaver from socket room and their sidebar
io.in(`user:${userId}`).socketsLeave(`group:${group.id}`);
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
if (group.is_direct) {
// Make remaining user owner so they can still manage the conversation
const remaining = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 1').get(group.id);
if (remaining) {
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?")
.run(remaining.user_id, group.id);
// DELETE leave
router.delete('/:id/leave', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator.' });
const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, userId]);
const mr = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[group.id, userId, `${leaverName} has left the conversation.`]
);
const sysMsg = await queryOne(req.schema,
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
[mr.rows[0].id]
);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
io.in(`user:${userId}`).socketsLeave(`group:${group.id}`);
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
if (group.is_direct) {
const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]);
if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]);
}
}
res.json({ success: true });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Admin take ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' });
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id);
res.json({ success: true });
// POST take-ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' });
await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [req.user.id, req.params.id]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete group
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
}
// Collect members before deleting
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id);
// Add all active users for public groups
if (group.type === 'public') {
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
}
// Collect all image files for this group before deleting
const imageMessages = db.prepare("SELECT image_url FROM messages WHERE group_id = ? AND image_url IS NOT NULL").all(group.id);
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
// Delete image files from disk after DB delete
for (const msg of imageMessages) deleteImageFile(msg.image_url);
// Notify all affected users
emitGroupDeleted(io, group.id, members);
res.json({ success: true });
// DELETE group
router.delete('/:id', authMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can delete' });
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [group.id])).map(m => m.user_id);
if (group.type === 'public') {
const all = await query(req.schema, "SELECT id FROM users WHERE status='active'");
for (const u of all) if (!members.includes(u.id)) members.push(u.id);
}
const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]);
for (const msg of imageMessages) deleteImageFile(msg.image_url);
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: group.id });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Set or update user's custom name for a group
router.patch('/:id/custom-name', authMiddleware, (req, res) => {
const db = getDb();
const groupId = parseInt(req.params.id);
const userId = req.user.id;
// PATCH custom-name
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) {
// Empty name = remove custom name (revert to owner name)
db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId);
return res.json({ success: true, name: null });
}
db.prepare(`
INSERT INTO user_group_names (user_id, group_id, name)
VALUES (?, ?, ?)
ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name
`).run(userId, groupId, name.trim());
res.json({ success: true, name: name.trim() });
const groupId = parseInt(req.params.id), userId = req.user.id;
try {
if (!name?.trim()) {
await exec(req.schema, 'DELETE FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, groupId]);
return res.json({ success: true, name: null });
}
await exec(req.schema,
'INSERT INTO user_group_names (user_id,group_id,name) VALUES ($1,$2,$3) ON CONFLICT (user_id,group_id) DO UPDATE SET name=EXCLUDED.name',
[userId, groupId, name.trim()]
);
res.json({ success: true, name: name.trim() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;

View File

@@ -1,40 +1,32 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { getDb } = require('../models/db');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const { exec, queryOne } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// help.md lives inside the backend source tree — NOT in /app/data which is
// volume-mounted and would hide files baked into the image at build time.
const HELP_FILE = path.join(__dirname, '../data/help.md');
// GET /api/help — returns markdown content
router.get('/', authMiddleware, (req, res) => {
let content = '';
const filePath = HELP_FILE;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
content = '# Getting Started\n\nHelp content is not available yet.';
}
try { content = fs.readFileSync(HELP_FILE, 'utf8'); }
catch (e) { content = '# Getting Started\n\nHelp content is not available yet.'; }
res.json({ content });
});
// GET /api/help/status — returns whether user has dismissed help
router.get('/status', authMiddleware, (req, res) => {
const db = getDb();
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
res.json({ dismissed: !!user?.help_dismissed });
router.get('/status', authMiddleware, async (req, res) => {
try {
const user = await queryOne(req.schema, 'SELECT help_dismissed FROM users WHERE id = $1', [req.user.id]);
res.json({ dismissed: !!user?.help_dismissed });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/help/dismiss — set help_dismissed for current user
router.post('/dismiss', authMiddleware, (req, res) => {
router.post('/dismiss', authMiddleware, async (req, res) => {
const { dismissed } = req.body;
const db = getDb();
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
.run(dismissed ? 1 : 0, req.user.id);
res.json({ success: true });
try {
await exec(req.schema, 'UPDATE users SET help_dismissed = $1 WHERE id = $2', [!!dismissed, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

312
backend/src/routes/host.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* routes/host.js — JAMA-HOST control plane
*
* All routes require the HOST_ADMIN_KEY header.
* These routes operate on the 'public' schema (tenant registry).
* They provision/deprovision per-tenant schemas.
*
* APP_TYPE must be 'host' for these routes to be registered.
*/
const express = require('express');
const router = express.Router();
const {
query, queryOne, queryResult, exec,
runMigrations, ensureSchema,
seedSettings, seedEventTypes, seedAdmin,
refreshTenantCache,
} = require('../models/db');
const HOST_ADMIN_KEY = process.env.HOST_ADMIN_KEY || '';
// ── Host admin key guard ──────────────────────────────────────────────────────
function hostAdminMiddleware(req, res, next) {
if (!HOST_ADMIN_KEY) {
return res.status(503).json({ error: 'HOST_ADMIN_KEY is not configured' });
}
const key = req.headers['x-host-admin-key'] || req.headers['authorization']?.replace('Bearer ', '');
if (!key || key !== HOST_ADMIN_KEY) {
return res.status(401).json({ error: 'Invalid host admin key' });
}
next();
}
// All routes in this file require the host admin key
router.use(hostAdminMiddleware);
// ── Helpers ───────────────────────────────────────────────────────────────────
function slugToSchema(slug) {
return `tenant_${slug.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
}
function isValidSlug(slug) {
return /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug);
}
async function reloadTenantCache() {
const tenants = await query('public', "SELECT * FROM tenants WHERE status = 'active'");
refreshTenantCache(tenants);
return tenants;
}
// ── GET /api/host/tenants — list all tenants ──────────────────────────────────
router.get('/tenants', async (req, res) => {
try {
const tenants = await query('public',
'SELECT * FROM tenants ORDER BY created_at DESC'
);
res.json({ tenants });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /api/host/tenants/:slug — get single tenant ───────────────────────────
router.get('/tenants/:slug', async (req, res) => {
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
res.json({ tenant });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/tenants — provision a new tenant ───────────────────────────
//
// Body: { slug, name, plan, adminEmail, adminName, adminPass, customDomain? }
//
// This:
// 1. Validates the slug (becomes subdomain + schema name)
// 2. Creates the Postgres schema
// 3. Runs all migrations in the new schema
// 4. Seeds settings, event types, and the first admin user
// 5. Records the tenant in the registry
// 6. Reloads the tenant domain cache
router.post('/tenants', async (req, res) => {
const { slug, name, plan, adminEmail, adminName, adminPass, customDomain } = req.body;
if (!slug || !name) return res.status(400).json({ error: 'slug and name are required' });
if (!isValidSlug(slug)) {
return res.status(400).json({
error: 'slug must be 3-32 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric'
});
}
const schemaName = slugToSchema(slug);
try {
// Check slug not already taken
const existing = await queryOne('public',
'SELECT id FROM tenants WHERE slug = $1', [slug]
);
if (existing) return res.status(400).json({ error: `Tenant '${slug}' already exists` });
if (customDomain) {
const domainTaken = await queryOne('public',
'SELECT id FROM tenants WHERE custom_domain = $1', [customDomain.toLowerCase()]
);
if (domainTaken) return res.status(400).json({ error: `Custom domain '${customDomain}' is already in use` });
}
console.log(`[Host] Provisioning tenant: ${slug} (schema: ${schemaName})`);
// 1. Create schema + run migrations
await runMigrations(schemaName);
// 2. Seed settings (uses env defaults unless overridden by body)
await seedSettings(schemaName);
// 3. Seed event types
await seedEventTypes(schemaName);
// 4. Seed admin user — temporarily override env vars for this tenant
const origEmail = process.env.ADMIN_EMAIL;
const origName = process.env.ADMIN_NAME;
const origPass = process.env.ADMIN_PASS;
if (adminEmail) process.env.ADMIN_EMAIL = adminEmail;
if (adminName) process.env.ADMIN_NAME = adminName;
if (adminPass) process.env.ADMIN_PASS = adminPass;
await seedAdmin(schemaName);
process.env.ADMIN_EMAIL = origEmail;
process.env.ADMIN_NAME = origName;
process.env.ADMIN_PASS = origPass;
// 5. Set app_type based on plan
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
if (plan === 'brand' || plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
}
if (plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_group_manager'");
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_schedule_manager'");
}
// 6. Register in tenants table
const tr = await queryResult('public', `
INSERT INTO tenants (slug, name, schema_name, custom_domain, plan, admin_email)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [slug, name, schemaName, customDomain?.toLowerCase() || null, plan || 'chat', adminEmail || null]);
// 7. Reload domain cache
await reloadTenantCache();
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`;
console.log(`[Host] Tenant provisioned: ${slug}${schemaName}`);
res.status(201).json({ tenant });
} catch (e) {
console.error(`[Host] Provisioning failed for ${slug}:`, e.message);
// Attempt cleanup of partially-created schema
try {
await exec('public', `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
console.log(`[Host] Cleaned up schema ${schemaName} after failed provision`);
} catch (cleanupErr) {
console.error(`[Host] Cleanup failed:`, cleanupErr.message);
}
res.status(500).json({ error: e.message });
}
});
// ── PATCH /api/host/tenants/:slug — update tenant ─────────────────────────────
//
// Supports updating: name, plan, customDomain, status
router.patch('/tenants/:slug', async (req, res) => {
const { name, plan, customDomain, status } = req.body;
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
if (customDomain && customDomain !== tenant.custom_domain) {
const taken = await queryOne('public',
'SELECT id FROM tenants WHERE custom_domain=$1 AND slug!=$2',
[customDomain.toLowerCase(), req.params.slug]
);
if (taken) return res.status(400).json({ error: 'Custom domain already in use' });
}
if (status && !['active','suspended'].includes(status))
return res.status(400).json({ error: 'status must be active or suspended' });
await exec('public', `
UPDATE tenants SET
name = COALESCE($1, name),
plan = COALESCE($2, plan),
custom_domain = $3,
status = COALESCE($4, status),
updated_at = NOW()
WHERE slug = $5
`, [name || null, plan || null, customDomain?.toLowerCase() ?? tenant.custom_domain, status || null, req.params.slug]);
// If plan changed, update feature flags in tenant schema
if (plan && plan !== tenant.plan) {
const s = tenant.schema_name;
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
}
await reloadTenantCache();
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
res.json({ tenant: updated });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── DELETE /api/host/tenants/:slug — deprovision tenant ───────────────────────
//
// Permanently drops the tenant's Postgres schema and all data.
// Requires confirmation: body must include { confirm: "DELETE {slug}" }
router.delete('/tenants/:slug', async (req, res) => {
const { confirm } = req.body;
if (confirm !== `DELETE ${req.params.slug}`) {
return res.status(400).json({
error: `Confirmation required. Send { "confirm": "DELETE ${req.params.slug}" } in the request body.`
});
}
try {
const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]
);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
console.log(`[Host] Deprovisioning tenant: ${req.params.slug} (schema: ${tenant.schema_name})`);
// Drop the entire schema — CASCADE removes all tables, indexes, triggers
await exec('public', `DROP SCHEMA IF EXISTS "${tenant.schema_name}" CASCADE`);
// Remove from registry
await exec('public', 'DELETE FROM tenants WHERE slug=$1', [req.params.slug]);
await reloadTenantCache();
console.log(`[Host] Tenant deprovisioned: ${req.params.slug}`);
res.json({ success: true, message: `Tenant '${req.params.slug}' and all its data have been permanently deleted.` });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/tenants/:slug/migrate — run pending migrations ─────────────
//
// Useful after deploying a new migration file to apply it to all tenants.
router.post('/tenants/:slug/migrate', async (req, res) => {
try {
const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
await runMigrations(tenant.schema_name);
const applied = await query(tenant.schema_name, 'SELECT * FROM schema_migrations ORDER BY version');
res.json({ success: true, migrations: applied });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /api/host/migrate-all — run pending migrations on every tenant ───────
router.post('/migrate-all', async (req, res) => {
try {
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
const results = [];
for (const t of tenants) {
try {
await runMigrations(t.schema_name);
results.push({ slug: t.slug, status: 'ok' });
} catch (e) {
results.push({ slug: t.slug, status: 'error', error: e.message });
}
}
res.json({ results });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /api/host/status — host health check ──────────────────────────────────
router.get('/status', async (req, res) => {
try {
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
res.json({
ok: true,
appType: process.env.APP_TYPE || 'selfhost',
baseDomain,
tenants: { total: parseInt(tenantCount.count), active: parseInt(active.count) },
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,219 +1,173 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { getDb } = require('../models/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { query, queryOne, queryResult, exec } = require('../models/db');
// Delete an uploaded image file from disk if it lives under /app/uploads/images
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try {
const filePath = '/app' + imageUrl; // imageUrl is like /uploads/images/img_xxx.jpg
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (e) {
console.warn('[Messages] Could not delete image file:', e.message);
}
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
catch (e) { console.warn('[Messages] Could not delete image:', e.message); }
}
module.exports = function(io) {
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
}
});
const uploadImage = multer({
storage: imgStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2,6)}${path.extname(file.originalname)}`),
});
const uploadImage = multer({ storage: imgStorage, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
function getUserForMessage(db, userId) {
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
}
function canAccessGroup(db, groupId, userId) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return null;
if (group.type === 'public') return group;
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return null;
return group;
}
// Get messages for group
router.get('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
const { before, limit = 50 } = req.query;
// For managed groups: find when this user joined so we can hide older messages
let joinedAt = null;
if (group.is_managed) {
const membership = db.prepare('SELECT joined_at FROM group_members WHERE group_id = ? AND user_id = ?').get(group.id, req.user.id);
if (membership?.joined_at) {
// Strip time — they can see messages from the start of the day they joined
joinedAt = membership.joined_at.slice(0, 10); // 'YYYY-MM-DD'
}
async function canAccessGroup(schema, groupId, userId) {
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
if (!group) return null;
if (group.type === 'public') return group;
const member = await queryOne(schema, 'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2', [groupId, userId]);
return member ? group : null;
}
let query = `
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm,
rm.content as reply_content, rm.image_url as reply_image_url,
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
rm.is_deleted as reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = ?
`;
const params = [req.params.groupId];
// GET messages for group
router.get('/group/:groupId', authMiddleware, async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
// Enforce join-date visibility for managed groups
if (joinedAt) {
query += ` AND date(m.created_at) >= ?`;
params.push(joinedAt);
}
const { before, limit = 50 } = req.query;
let joinedAt = null;
if (group.is_managed) {
const membership = await queryOne(req.schema,
'SELECT joined_at FROM group_members WHERE group_id=$1 AND user_id=$2',
[group.id, req.user.id]
);
if (membership?.joined_at) joinedAt = new Date(membership.joined_at).toISOString().slice(0,10);
}
if (before) {
query += ' AND m.id < ?';
params.push(before);
}
let sql = `
SELECT m.*,
u.name AS user_name, u.display_name AS user_display_name,
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm,
rm.content AS reply_content, rm.image_url AS reply_image_url,
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name,
rm.is_deleted AS reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = $1
`;
const params = [req.params.groupId];
let pi = 2;
if (joinedAt) { sql += ` AND m.created_at::date >= $${pi++}::date`; params.push(joinedAt); }
if (before) { sql += ` AND m.id < $${pi++}`; params.push(before); }
sql += ` ORDER BY m.created_at DESC LIMIT $${pi}`;
params.push(parseInt(limit));
query += ' ORDER BY m.created_at DESC LIMIT ?';
params.push(parseInt(limit));
const messages = await query(req.schema, sql, params);
for (const msg of messages) {
msg.reactions = await query(req.schema,
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
[msg.id]
);
}
res.json({ messages: messages.reverse() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
const messages = db.prepare(query).all(...params);
// POST send message
router.post('/group/:groupId', authMiddleware, async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const r = await queryResult(req.schema,
'INSERT INTO messages (group_id,user_id,content,reply_to_id,link_preview) VALUES ($1,$2,$3,$4,$5) RETURNING id',
[req.params.groupId, req.user.id, content?.trim()||null, replyToId||null, linkPreview ? JSON.stringify(linkPreview) : null]
);
const message = await queryOne(req.schema, `
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar, u.role AS user_role, u.allow_dm AS user_allow_dm,
rm.content AS reply_content, ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
FROM messages m JOIN users u ON m.user_id=u.id
LEFT JOIN messages rm ON m.reply_to_id=rm.id LEFT JOIN users ru ON rm.user_id=ru.id
WHERE m.id=$1
`, [r.rows[0].id]);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get reactions for these messages
for (const msg of messages) {
msg.reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(msg.id);
}
// POST image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), async (req, res) => {
try {
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const r = await queryResult(req.schema,
"INSERT INTO messages (group_id,user_id,content,image_url,type,reply_to_id) VALUES ($1,$2,$3,$4,'image',$5) RETURNING id",
[req.params.groupId, req.user.id, content||null, imageUrl, replyToId||null]
);
const message = await queryOne(req.schema,
'SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar, u.role AS user_role, u.allow_dm AS user_allow_dm FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
[r.rows[0].id]
);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
} catch (e) { res.status(500).json({ error: e.message }); }
});
res.json({ messages: messages.reverse() });
});
// DELETE message
router.delete('/:id', authMiddleware, async (req, res) => {
try {
const message = await queryOne(req.schema,
'SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1',
[req.params.id]
);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id || req.user.role === 'admin' ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
const imageUrl = message.image_url;
await exec(req.schema, 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1', [message.id]);
deleteImageFile(imageUrl);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
res.json({ success: true, messageId: message.id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Send message
router.post('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
// POST reaction
router.post('/:id/reactions', authMiddleware, async (req, res) => {
const { emoji } = req.body;
try {
const message = await queryOne(req.schema, 'SELECT * FROM messages WHERE id=$1 AND is_deleted=FALSE', [req.params.id]);
if (!message) return res.status(404).json({ error: 'Message not found' });
const existing = await queryOne(req.schema,
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3',
[message.id, req.user.id, emoji]
);
if (existing) {
await exec(req.schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
} else {
await exec(req.schema, 'INSERT INTO reactions (message_id,user_id,emoji) VALUES ($1,$2,$3)', [message.id, req.user.id, emoji]);
}
const reactions = await query(req.schema,
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
[message.id]
);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions });
res.json({ reactions });
} catch (e) { res.status(500).json({ error: e.message }); }
});
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?)
`).run(req.params.groupId, req.user.id, content?.trim() || null, replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.allow_dm as user_allow_dm,
rm.content as reply_content, ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Upload image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id)
VALUES (?, ?, ?, ?, 'image', ?)
`).run(req.params.groupId, req.user.id, content || null, imageUrl, replyToId || null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.allow_dm as user_allow_dm
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
io.to(`group:${req.params.groupId}`).emit('message:new', message);
res.json({ message });
});
// Delete message
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_readonly FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id ||
req.user.role === 'admin' ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
const imageUrl = message.image_url;
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.id);
deleteImageFile(imageUrl);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
res.json({ success: true, messageId: message.id });
});
// Add/toggle reaction
router.post('/:id/reactions', authMiddleware, (req, res) => {
const { emoji } = req.body;
const db = getDb();
const message = db.prepare('SELECT * FROM messages WHERE id = ? AND is_deleted = 0').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
// Check if user's message is from deleted/suspended user
const msgUser = db.prepare('SELECT status FROM users WHERE id = ?').get(message.user_id);
if (msgUser.status !== 'active') return res.status(400).json({ error: 'Cannot react to this message' });
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(message.id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
}
const reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(message.id);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId: message.id, reactions });
res.json({ reactions });
});
return router;
return router;
};

View File

@@ -1,104 +1,112 @@
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { getDb } = require('../models/db');
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// Get or generate VAPID keys stored in settings
function getVapidKeys() {
const db = getDb();
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
// VAPID keys are stored in settings; lazily initialised on first request
let vapidPublicKey = null;
async function getVapidKeys(schema) {
const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'");
const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'");
if (!pub?.value || !priv?.value) {
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
console.log('[Push] Generated new VAPID keys');
return keys;
}
return { publicKey: pub.value, privateKey: priv.value };
}
function initWebPush() {
const keys = getVapidKeys();
webpush.setVapidDetails(
'mailto:admin@jama.local',
keys.publicKey,
keys.privateKey
);
async function initWebPush(schema) {
const keys = await getVapidKeys(schema);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
return keys.publicKey;
}
// Export for use in index.js
let vapidPublicKey = null;
function getVapidPublicKey() {
if (!vapidPublicKey) vapidPublicKey = initWebPush();
return vapidPublicKey;
}
// Send a push notification to all subscriptions for a user
async function sendPushToUser(userId, payload) {
const db = getDb();
getVapidPublicKey(); // ensure webpush is configured
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired — remove it
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
// Called from index.js socket push notifications — schema comes from caller
async function sendPushToUser(schema, userId, payload) {
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
}
}
}
} catch (e) {
console.error('[Push] sendPushToUser error:', e.message);
}
}
// GET /api/push/vapid-public — returns VAPID public key for client subscription
router.get('/vapid-public', (req, res) => {
res.json({ publicKey: getVapidPublicKey() });
router.get('/vapid-public', async (req, res) => {
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema);
res.json({ publicKey: vapidPublicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/subscribe — save push subscription for current user
router.post('/subscribe', authMiddleware, (req, res) => {
router.post('/subscribe', authMiddleware, async (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
if (!endpoint || !keys?.p256dh || !keys?.auth)
return res.status(400).json({ error: 'Invalid subscription' });
}
const db = getDb();
const device = req.device || 'desktop';
// Delete any existing subscription for this user+device or this endpoint, then insert fresh
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? OR (user_id = ? AND device = ?)').run(endpoint, req.user.id, device);
db.prepare('INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?)').run(req.user.id, device, endpoint, keys.p256dh, keys.auth);
res.json({ success: true });
try {
const device = req.device || 'desktop';
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
[endpoint, req.user.id, device]
);
await exec(req.schema,
'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)',
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/generate-vapid — admin: generate (or regenerate) VAPID keys
router.post('/generate-vapid', authMiddleware, (req, res) => {
router.post('/generate-vapid', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
const db = getDb();
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
// Reinitialise webpush with new keys immediately
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
console.log('[Push] VAPID keys regenerated by admin');
res.json({ publicKey: keys.publicKey });
try {
const keys = webpush.generateVAPIDKeys();
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
res.json({ publicKey: keys.publicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/push/unsubscribe — remove subscription
router.post('/unsubscribe', authMiddleware, (req, res) => {
router.post('/unsubscribe', authMiddleware, async (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
res.json({ success: true });
try {
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
[req.user.id, endpoint]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = { router, sendPushToUser, getVapidPublicKey };
module.exports = { router, sendPushToUser };

View File

@@ -1,396 +1,378 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const express = require('express');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer');
const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
// ── Helpers ───────────────────────────────────────────────────────────────────
function canViewEvent(db, event, userId, isToolManager) {
if (isToolManager) return true;
if (event.is_public) return true;
// Private: user must be in an assigned user group
const assigned = db.prepare(`
async function isToolManagerFn(schema, user) {
if (user.role === 'admin') return true;
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
if (!groupIds.length) return false;
const ph = groupIds.map((_,i) => `$${i+2}`).join(',');
return !!(await queryOne(schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [user.id, ...groupIds]));
}
async function canViewEvent(schema, event, userId, isToolManager) {
if (isToolManager || event.is_public) return true;
const assigned = await queryOne(schema, `
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, userId);
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, userId]);
return !!assigned;
}
function isToolManagerFn(db, user) {
if (user.role === 'admin') return true;
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
const groupIds = [...new Set([
...JSON.parse(tmSetting?.value || '[]'),
...JSON.parse(gmSetting?.value || '[]'),
])];
if (!groupIds.length) return false;
return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds);
async function enrichEvent(schema, event) {
event.event_type = event.event_type_id
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
: null;
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
event.user_groups = await query(schema, `
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id=eug.user_group_id WHERE eug.event_id=$1
`, [event.id]);
return event;
}
function enrichEvent(db, event) {
event.event_type = event.event_type_id
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
: null;
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
await exec(schema, `
UPDATE events SET
title = COALESCE($1, title),
event_type_id = $2,
start_at = COALESCE($3, start_at),
end_at = COALESCE($4, end_at),
all_day = COALESCE($5, all_day),
location = $6,
description = $7,
is_public = COALESCE($8, is_public),
track_availability = COALESCE($9, track_availability),
recurrence_rule = $10,
updated_at = NOW()
WHERE id = $11
`, [
title?.trim() || null,
eventTypeId !== undefined ? (eventTypeId || null) : origEvent.event_type_id,
startAt || null,
endAt || null,
allDay !== undefined ? allDay : null,
location !== undefined ? (location || null) : origEvent.location,
description !== undefined ? (description || null) : origEvent.description,
isPublic !== undefined ? isPublic : null,
trackAvailability !== undefined ? trackAvailability : null,
recurrenceRule !== undefined ? recurrenceRule : origEvent.recurrence_rule,
eventId,
]);
if (Array.isArray(userGroupIds)) {
await exec(schema, 'DELETE FROM event_user_groups WHERE event_id=$1', [eventId]);
for (const ugId of userGroupIds)
await exec(schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
}
event.user_groups = db.prepare(`
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id = eug.user_group_id
WHERE eug.event_id = ?
`).all(event.id);
return event;
}
// ── Event Types ───────────────────────────────────────────────────────────────
router.get('/event-types', authMiddleware, (req, res) => {
const db = getDb();
res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() });
router.get('/event-types', authMiddleware, async (req, res) => {
try {
const eventTypes = await query(req.schema, 'SELECT * FROM event_types ORDER BY is_default DESC, name ASC');
res.json({ eventTypes });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => {
router.post('/event-types', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
return res.status(400).json({ error: 'Event type with that name already exists' });
}
const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs)
VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) });
try {
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [name.trim()]))
return res.status(400).json({ error: 'Event type with that name already exists' });
const r = await queryResult(req.schema,
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
);
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [r.rows[0].id]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id))
return res.status(400).json({ error: 'Name already in use' });
}
db.prepare(`UPDATE event_types SET
name = COALESCE(?, name),
colour = COALESCE(?, colour),
default_user_group_id = ?,
default_duration_hrs = COALESCE(?, default_duration_hrs)
WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) });
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), et.id]))
return res.status(400).json({ error: 'Name already in use' });
}
await exec(req.schema, `
UPDATE event_types SET
name = COALESCE($1, name),
colour = COALESCE($2, colour),
default_user_group_id = $3,
default_duration_hrs = COALESCE($4, default_duration_hrs)
WHERE id=$5
`, [name?.trim()||null, colour||null, defaultUserGroupId??et.default_user_group_id, defaultDurationHrs||null, et.id]);
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [et.id]) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
// Null out event_type_id on events using this type
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
res.json({ success: true });
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Events ────────────────────────────────────────────────────────────────────
// List events (with optional date range filter)
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const itm = isToolManagerFn(db, req.user);
const { from, to } = req.query;
let q = 'SELECT * FROM events WHERE 1=1';
const params = [];
if (from) { q += ' AND end_at >= ?'; params.push(from); }
if (to) { q += ' AND start_at <= ?'; params.push(to); }
q += ' ORDER BY start_at ASC';
const events = db.prepare(q).all(...params)
.filter(e => canViewEvent(db, e, req.user.id, itm))
.map(e => {
enrichEvent(db, e);
// Include current user's response so the list can show the awaiting indicator
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(e.id, req.user.id);
router.get('/', authMiddleware, async (req, res) => {
try {
const itm = await isToolManagerFn(req.schema, req.user);
const { from, to } = req.query;
let sql = 'SELECT * FROM events WHERE 1=1';
const params = [];
let pi = 1;
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
sql += ' ORDER BY start_at ASC';
const rawEvents = await query(req.schema, sql, params);
const events = [];
for (const e of rawEvents) {
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
await enrichEvent(req.schema, e);
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [e.id, req.user.id]);
e.my_response = mine?.response || null;
return e;
});
res.json({ events });
events.push(e);
}
res.json({ events });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get single event
router.get('/:id', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = isToolManagerFn(db, req.user);
if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' });
enrichEvent(db, event);
// Availability (only for assigned group members / tool managers)
if (event.track_availability && itm) {
const responses = db.prepare(`
SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id = ea.user_id
WHERE ea.event_id = ?
`).all(req.params.id);
event.availability = responses;
// Count no-response: users in assigned groups who haven't responded
const assignedUserIds = db.prepare(`
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ?
`).all(req.params.id).map(r => r.user_id);
const respondedIds = new Set(responses.map(r => r.user_id));
event.no_response_count = assignedUserIds.filter(id => !respondedIds.has(id)).length;
}
// Current user's own response
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(req.params.id, req.user.id);
event.my_response = mine?.response || null;
res.json({ event });
router.get('/me/pending', authMiddleware, async (req, res) => {
try {
const pending = await query(req.schema, `
SELECT DISTINCT e.* FROM events e
JOIN event_user_groups eug ON eug.event_id=e.id
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE ugm.user_id=$1 AND e.track_availability=TRUE
AND e.end_at >= NOW()
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
ORDER BY e.start_at ASC
`, [req.user.id]);
const result = [];
for (const e of pending) result.push(await enrichEvent(req.schema, e));
res.json({ events: result });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create event
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
router.get('/:id', authMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = await isToolManagerFn(req.schema, req.user);
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
await enrichEvent(req.schema, event);
if (event.track_availability && itm) {
event.availability = await query(req.schema, `
SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]);
const assignedIds = (await query(req.schema, `
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id WHERE eug.event_id=$1
`, [req.params.id])).map(r => r.user_id);
const respondedIds = new Set(event.availability.map(r => r.user_id));
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length;
}
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
event.my_response = mine?.response || null;
res.json({ event });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
const db = getDb();
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
title.trim(), eventTypeId || null, startAt, endAt,
allDay ? 1 : 0, location || null, description || null,
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
);
const eventId = r.lastInsertRowid;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId);
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
res.json({ event: enrichEvent(db, event) });
try {
const r = await queryResult(req.schema, `
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
const eventId = r.rows[0].id;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
res.json({ event: await enrichEvent(req.schema, event) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update event
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability),
recurrence_rule = ?,
updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
req.params.id
);
// For recurring events: if scope='future', update all future occurrences too
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = db.prepare(`
SELECT id FROM events
WHERE id != ? AND created_by = ? AND recurrence_rule IS NOT NULL
AND start_at >= ? AND title = ?
`).all(req.params.id, event.created_by, event.start_at, event.title);
for (const fe of futureEvents) {
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability),
recurrence_rule = ?,
updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
fe.id
);
if (Array.isArray(userGroupIds)) {
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(fe.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(fe.id, ugId);
}
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
// Recurring future scope — update all future occurrences
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
AND start_at >= $3 AND title=$4
`, [req.params.id, event.created_by, event.start_at, event.title]);
for (const fe of futureEvents)
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
}
}
if (Array.isArray(userGroupIds)) {
// Find which groups are being removed
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')
.all(req.params.id).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
// Remove availability responses for users who are only in removed groups
for (const removedGid of removedGroupIds) {
const removedUserIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?')
.all(removedGid).map(r => r.user_id);
for (const uid of removedUserIds) {
// Check if user is still in ANY remaining group for this event
const stillAssigned = newGroupSet.size > 0 && db.prepare(`
SELECT 1 FROM user_group_members
WHERE user_id = ? AND user_group_id IN (${[...newGroupSet].map(()=>'?').join(',')})
`).get(uid, ...[...newGroupSet]);
if (!stillAssigned) {
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?')
.run(req.params.id, uid);
// Clean up availability for users removed from groups
if (Array.isArray(userGroupIds)) {
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
const newGroupSet = new Set(userGroupIds.map(Number));
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
for (const removedGid of removedGroupIds) {
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
for (const uid of removedUids) {
if (newGroupSet.size > 0) {
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
if (stillAssigned) continue;
}
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
}
}
}
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId);
}
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
res.json({ event: enrichEvent(db, updated) });
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
res.json({ event: await enrichEvent(req.schema, updated) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete event
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
res.json({ success: true });
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
return res.status(404).json({ error: 'Not found' });
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Availability ──────────────────────────────────────────────────────────────
// Submit/update availability
router.put('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' });
const { response } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
// User must be in an assigned group
const inGroup = db.prepare(`
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')
`).run(event.id, req.user.id, response, response);
res.json({ success: true, response });
router.put('/:id/availability', authMiddleware, async (req, res) => {
try {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
const { response } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
`, [event.id, req.user.id, response]);
res.json({ success: true, response });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete availability (withdraw response)
router.delete('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
router.delete('/:id/availability', authMiddleware, async (req, res) => {
try {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Get pending availability for current user (events they need to respond to)
router.get('/me/pending', authMiddleware, (req, res) => {
const db = getDb();
const pending = db.prepare(`
SELECT e.* FROM events e
JOIN event_user_groups eug ON eug.event_id = e.id
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE ugm.user_id = ? AND e.track_availability = 1
AND e.end_at >= datetime('now')
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id = e.id AND ea.user_id = ?)
ORDER BY e.start_at ASC
`).all(req.user.id, req.user.id);
res.json({ events: pending.map(e => enrichEvent(db, e)) });
});
// Bulk availability response
router.post('/me/bulk-availability', authMiddleware, (req, res) => {
const { responses } = req.body; // [{ eventId, response }]
router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
const { responses } = req.body;
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
const db = getDb();
const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`);
let saved = 0;
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
if (!event || !event.track_availability) continue;
const inGroup = db.prepare(`SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id WHERE eug.event_id = ? AND ugm.user_id = ?`).get(eventId, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) continue;
stmt.run(eventId, req.user.id, response, response);
saved++;
}
res.json({ success: true, saved });
try {
let saved = 0;
const itm = await isToolManagerFn(req.schema, req.user);
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
if (!event || !event.track_availability) continue;
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [eventId, req.user.id]);
if (!inGroup && !itm) continue;
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
`, [eventId, req.user.id, response]);
saved++;
}
res.json({ success: true, saved });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── CSV Bulk Import ───────────────────────────────────────────────────────────
// ── CSV Import ────────────────────────────────────────────────────────────────
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => {
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true });
const db = getDb();
const results = rows.map((row, i) => {
const rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
const results = await Promise.all(rows.map(async (row, i) => {
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
const startDate = row['start_date'] || row['Start Date'] || '';
const startTime = row['start_time'] || row['Start Time'] || '09:00';
const startDate = row['start_date'] || row['Start Date'] || '';
const startTime = row['start_time'] || row['Start Time'] || '09:00';
const location = row['event_location'] || row['location'] || '';
const typeName = row['event_type'] || row['Event Type'] || 'Default';
const typeName = row['event_type'] || row['Event Type'] || 'Default';
const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1;
if (!title || !startDate) return { row: i + 1, title, error: 'Missing title or start date', duplicate: false };
if (!title || !startDate) return { row:i+1, title, error:'Missing title or start date', duplicate:false };
const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
// Check duplicate
const dup = db.prepare('SELECT id, title FROM events WHERE title = ? AND start_at = ?').get(title, startAt);
return { row: i+1, title, startAt, endAt, location, typeName, durHrs, duplicate: !!dup, duplicateId: dup?.id, error: null };
});
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
const dup = await queryOne(req.schema, 'SELECT id,title FROM events WHERE title=$1 AND start_at=$2', [title, startAt]);
return { row:i+1, title, startAt, endAt, location, typeName, durHrs, duplicate:!!dup, duplicateId:dup?.id, error:null };
}));
res.json({ rows: results });
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
});
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => {
const { rows } = req.body; // filtered rows from preview (client excludes skipped)
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { rows } = req.body;
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
const db = getDb();
let imported = 0;
const stmt = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, location, is_public, track_availability, created_by)
VALUES (?, ?, ?, ?, ?, 1, 0, ?)`);
for (const row of rows) {
if (row.error || row.skip) continue;
let typeId = null;
if (row.typeName) {
let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName);
if (!et) {
// Create missing type with random colour
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour);
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour);
typeId = r2.lastInsertRowid;
} else { typeId = et.id; }
try {
let imported = 0;
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
for (const row of rows) {
if (row.error || row.skip) continue;
let typeId = null;
if (row.typeName) {
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
if (!et) {
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
typeId = cr.rows[0].id;
} else { typeId = et.id; }
}
await exec(req.schema,
'INSERT INTO events (title,event_type_id,start_at,end_at,location,is_public,track_availability,created_by) VALUES ($1,$2,$3,$4,$5,TRUE,FALSE,$6)',
[row.title, typeId, row.startAt, row.endAt, row.location||null, req.user.id]
);
imported++;
}
stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id);
imported++;
}
res.json({ success: true, imported });
res.json({ success: true, imported });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,190 +1,148 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { getDb } = require('../models/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { query, queryOne, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Generic icon storage factory
function makeIconStorage(prefix) {
return multer.diskStorage({
destination: '/app/uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${prefix}_${Date.now()}${ext}`);
}
filename: (req, file, cb) => cb(null, `${prefix}_${Date.now()}${path.extname(file.originalname)}`),
});
}
const iconUploadOpts = {
const iconOpts = {
limits: { fileSize: 1 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
};
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconOpts });
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
// Helper: upsert a setting
async function setSetting(schema, key, value) {
await exec(schema,
"INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2, updated_at=NOW()",
[key, value]
);
}
// Get public settings (accessible by all)
router.get('/', (req, res) => {
const db = getDb();
const settings = db.prepare('SELECT key, value FROM settings').all();
const obj = {};
for (const s of settings) obj[s.key] = s.value;
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email;
// Expose app version from Docker build arg env var
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj });
// GET /api/settings
router.get('/', async (req, res) => {
try {
const rows = await query(req.schema, 'SELECT key, value FROM settings');
const obj = {};
for (const r of rows) obj[r.key] = r.value;
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
if (admin) obj.admin_email = admin.email;
obj.app_version = process.env.JAMA_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update app name (admin)
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/app-name', authMiddleware, adminMiddleware, async (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
res.json({ success: true, name: name.trim() });
try {
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [name.trim()]);
res.json({ success: true, name: name.trim() });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const logoUrl = `/uploads/logos/${req.file.filename}`;
const srcPath = req.file.path;
try {
// Generate PWA icons from the uploaded logo
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
await sharp(srcPath)
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon192Path);
await sharp(srcPath)
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon512Path);
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
// Store the PWA icon paths so the manifest can reference them
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
await sharp(req.file.path).resize(192,192,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-192.png');
await sharp(req.file.path).resize(512,512,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-512.png');
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
await setSetting(req.schema, 'pwa_icon_192', '/uploads/logos/pwa-icon-192.png');
await setSetting(req.schema, 'pwa_icon_512', '/uploads/logos/pwa-icon-512.png');
res.json({ logoUrl });
} catch (err) {
console.error('[Logo] Failed to generate PWA icons:', err.message);
// Still save the logo even if icon generation fails
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
console.error('[Logo] icon gen failed:', err.message);
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
res.json({ logoUrl });
}
});
// Upload New Chat icon (admin)
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
try { await setSetting(req.schema, 'icon_newchat', iconUrl); res.json({ iconUrl }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload Group Info icon (admin)
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
try { await setSetting(req.schema, 'icon_groupinfo', iconUrl); res.json({ iconUrl }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// Reset all settings to defaults (admin)
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
if (colorTitleDark !== undefined) upd.run('color_title_dark', colorTitleDark || '', colorTitleDark || '');
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
res.json({ success: true });
try {
if (colorTitle !== undefined) await setSetting(req.schema, 'color_title', colorTitle || '');
if (colorTitleDark !== undefined) await setSetting(req.schema, 'color_title_dark', colorTitleDark || '');
if (colorAvatarPublic !== undefined) await setSetting(req.schema, 'color_avatar_public', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) await setSetting(req.schema, 'color_avatar_dm', colorAvatarDm || '');
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const originalName = process.env.APP_NAME || 'jama';
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512', 'color_title', 'color_title_dark', 'color_avatar_public', 'color_avatar_dm')").run();
res.json({ success: true });
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
try {
const originalName = process.env.APP_NAME || 'jama';
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Registration code ─────────────────────────────────────────────────────────
// Valid codes — in production these would be stored/validated server-side
const VALID_CODES = {
// JAMA-Team: full access — chat, branding, group manager, schedule manager
'JAMA-TEAM-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
// JAMA-Brand: chat + branding only
'JAMA-BRAND-2024': { appType: 'JAMA-Brand', branding: true, groupManager: false, scheduleManager: false },
// Legacy codes — map to new tiers
'JAMA-FULL-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
};
router.post('/register', authMiddleware, adminMiddleware, (req, res) => {
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
const { code } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (!code?.trim()) {
// Clear registration
upd.run('registration_code', '', '');
upd.run('app_type', 'JAMA-Chat', 'JAMA-Chat');
upd.run('feature_branding', 'false', 'false');
upd.run('feature_group_manager', 'false', 'false');
upd.run('feature_schedule_manager', 'false', 'false');
return res.json({ success: true, features: { branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat' } });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
upd.run('registration_code', code.trim(), code.trim());
upd.run('app_type', match.appType || 'JAMA-Chat', match.appType || 'JAMA-Chat');
upd.run('feature_branding', match.branding ? 'true' : 'false', match.branding ? 'true' : 'false');
upd.run('feature_group_manager', match.groupManager ? 'true' : 'false', match.groupManager ? 'true' : 'false');
upd.run('feature_schedule_manager', match.scheduleManager ? 'true' : 'false', match.scheduleManager ? 'true' : 'false');
res.json({ success: true, features: { branding: match.branding, groupManager: match.groupManager, scheduleManager: match.scheduleManager, appType: match.appType } });
try {
if (!code?.trim()) {
await setSetting(req.schema, 'registration_code', '');
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
await setSetting(req.schema, 'feature_branding', 'false');
await setSetting(req.schema, 'feature_group_manager', 'false');
await setSetting(req.schema, 'feature_schedule_manager', 'false');
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
await setSetting(req.schema, 'registration_code', code.trim());
await setSetting(req.schema, 'app_type', match.appType);
await setSetting(req.schema, 'feature_branding', match.branding ? 'true' : 'false');
await setSetting(req.schema, 'feature_group_manager', match.groupManager ? 'true' : 'false');
await setSetting(req.schema, 'feature_schedule_manager', match.scheduleManager ? 'true' : 'false');
res.json({ success:true, features:{ branding:match.branding, groupManager:match.groupManager, scheduleManager:match.scheduleManager, appType:match.appType } });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Save team management group assignments
router.patch('/team', authMiddleware, adminMiddleware, (req, res) => {
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
const { toolManagers } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (toolManagers !== undefined) {
const val = JSON.stringify(toolManagers || []);
upd.run('team_tool_managers', val, val);
// Keep legacy keys in sync so existing teamManagerMiddleware still works
upd.run('team_group_managers', val, val);
upd.run('team_schedule_managers', val, val);
}
res.json({ success: true });
try {
if (toolManagers !== undefined) {
const val = JSON.stringify(toolManagers || []);
await setSetting(req.schema, 'team_tool_managers', val);
await setSetting(req.schema, 'team_group_managers', val);
await setSetting(req.schema, 'team_schedule_managers', val);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -1,302 +1,313 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
module.exports = function(io) {
// ── Helpers ───────────────────────────────────────────────────────────────────
function postSysMsg(db, groupId, actorId, content) {
const r = db.prepare(`INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system')`).run(groupId, actorId, content);
const msg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(r.lastInsertRowid);
async function postSysMsg(schema, groupId, actorId, content) {
const r = await queryResult(schema,
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
[groupId, actorId, content]
);
const msg = await queryOne(schema, `
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm
FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1
`, [r.rows[0].id]);
if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); }
}
// Add user silently — no system message (used during initial creation)
function addUserSilent(db, dmGroupId, userId) {
db.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id, joined_at) VALUES (?, ?, datetime('now'))").run(dmGroupId, userId);
async function addUserSilent(schema, dmGroupId, userId) {
await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]);
io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`);
const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId);
const dmGroup = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
if (dmGroup) io.to(`user:${userId}`).emit('group:new', { group: dmGroup });
}
// Add user with system message (used when editing existing group)
function addUser(db, dmGroupId, userId, actorId) {
addUserSilent(db, dmGroupId, userId);
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has joined the conversation.`);
async function addUser(schema, dmGroupId, userId, actorId) {
await addUserSilent(schema, dmGroupId, userId);
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
}
// Remove user with system message
function removeUser(db, dmGroupId, userId, actorId) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(dmGroupId, userId);
async function removeUser(schema, dmGroupId, userId, actorId) {
await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]);
io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`);
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
}
function getUserIdsForGroup(db, userGroupId) {
return db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(userGroupId).map(r => r.user_id);
async function getUserIdsForGroup(schema, userGroupId) {
const rows = await query(schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [userGroupId]);
return rows.map(r => r.user_id);
}
// ── Current user's group memberships (no admin required) ────────────────────────
router.get('/me', authMiddleware, (req, res) => {
const db = getDb();
const groupIds = db.prepare('SELECT user_group_id FROM user_group_members WHERE user_id = ?').all(req.user.id).map(r => r.user_group_id);
res.json({ groupIds });
});
// ── MULTI-GROUP DMs — must come before /:id ───────────────────────────────────
router.get('/multigroup', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const dms = db.prepare(`
SELECT mgd.*,
(SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id = mgd.id) as group_count
FROM multi_group_dms mgd ORDER BY mgd.name ASC
`).all();
for (const dm of dms) {
dm.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(dm.id).map(r => r.user_group_id);
}
res.json({ dms });
});
router.post('/multigroup', authMiddleware, teamManagerMiddleware, (req, res) => {
const { name, userGroupIds = [] } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
if (userGroupIds.length < 2) return res.status(400).json({ error: 'At least two user groups required' });
const db = getDb();
if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
return res.status(400).json({ error: 'Name already in use' });
}
// Check for duplicate user group set
const newGroupIds = [...new Set(userGroupIds.map(Number).filter(Boolean))].sort();
const allDms = db.prepare('SELECT id, name FROM multi_group_dms').all();
for (const existing of allDms) {
const existingIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(existing.id).map(r => r.user_group_id).sort();
if (existingIds.length === newGroupIds.length && existingIds.every((id, i) => id === newGroupIds[i])) {
return res.status(400).json({ error: `DM not created — "${existing.name}" already exists with the same member groups.` });
// GET /me — current user's user-group memberships
router.get('/me', authMiddleware, async (req, res) => {
try {
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.user.id]);
const groupIds = rows.map(r => r.user_group_id);
if (groupIds.length === 0) return res.json({ userGroups: [] });
const placeholders = groupIds.map((_,i) => `$${i+1}`).join(',');
const userGroups = await query(req.schema, `SELECT * FROM user_groups WHERE id IN (${placeholders}) ORDER BY name ASC`, groupIds);
// Also resolve multi-group DMs this user can see
const mgDms = await query(req.schema, `
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
FROM multi_group_dms mgd
JOIN multi_group_dm_members mgdm ON mgdm.multi_group_dm_id=mgd.id
WHERE mgdm.user_group_id IN (${placeholders})
GROUP BY mgd.id ORDER BY mgd.name ASC
`, groupIds);
for (const dm of mgDms) {
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
}
}
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
const dmResult = db.prepare(`INSERT INTO groups (name, type, owner_id, is_managed) VALUES (?, 'private', ?, 1)`).run(name.trim(), admin?.id || req.user.id);
const dmGroupId = dmResult.lastInsertRowid;
const mgResult = db.prepare(`INSERT INTO multi_group_dms (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId);
const mgId = mgResult.lastInsertRowid;
const validGroupIds = userGroupIds.map(Number).filter(Boolean);
const addedUsers = new Set();
for (const ugId of validGroupIds) {
db.prepare('INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)').run(mgId, ugId);
for (const uid of getUserIdsForGroup(db, ugId)) {
if (!addedUsers.has(uid)) { addedUsers.add(uid); addUserSilent(db, dmGroupId, uid); }
}
}
const dm = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(mgId);
dm.memberGroupIds = validGroupIds;
dm.group_count = validGroupIds.length;
res.json({ dm });
res.json({ userGroups, multiGroupDms: mgDms });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
if (!mg) return res.status(404).json({ error: 'Not found' });
// GET /multigroup
router.get('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const dms = await query(req.schema, `
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
FROM multi_group_dms mgd ORDER BY mgd.name ASC
`);
for (const dm of dms) {
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
}
res.json({ dms });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /multigroup
router.post('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, userGroupIds } = req.body;
if (name && name.trim() !== mg.name) {
if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), mg.id)) {
return res.status(400).json({ error: 'Name already in use' });
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
if (!Array.isArray(userGroupIds) || userGroupIds.length < 2) return res.status(400).json({ error: 'At least 2 groups required' });
try {
// Check for existing DM with same groups
const existing = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE LOWER(name)=LOWER($1)', [name.trim()]);
if (existing) {
const existingIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [existing.id])).map(r => r.user_group_id).sort();
const newIds = [...userGroupIds].map(Number).sort();
if (JSON.stringify(existingIds) === JSON.stringify(newIds)) return res.status(400).json({ error: 'A DM with these groups already exists' });
}
db.prepare("UPDATE multi_group_dms SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.id);
if (mg.dm_group_id) db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.dm_group_id);
}
// Create the chat group
const gr = await queryResult(req.schema,
"INSERT INTO groups (name,type,is_readonly,is_managed,is_multi_group) VALUES ($1,'private',FALSE,TRUE,TRUE) RETURNING id",
[name.trim()]
);
const dmGroupId = gr.rows[0].id;
// Create multi_group_dms record
const mgr = await queryResult(req.schema,
'INSERT INTO multi_group_dms (name,dm_group_id) VALUES ($1,$2) RETURNING id',
[name.trim(), dmGroupId]
);
const mgId = mgr.rows[0].id;
// Add each user group and their members
const addedUsers = new Set();
for (const ugId of userGroupIds) {
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mgId, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) {
if (!addedUsers.has(uid)) {
addedUsers.add(uid);
await addUserSilent(req.schema, dmGroupId, uid);
}
}
}
const dmGroup = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
res.json({ dm: { id: mgId, name: name.trim(), dm_group_id: dmGroupId, group_count: userGroupIds.length }, group: dmGroup });
} catch (e) { res.status(500).json({ error: e.message }); }
});
if (Array.isArray(userGroupIds) && mg.dm_group_id) {
const newGroupIds = new Set(userGroupIds.map(Number).filter(Boolean));
const currentGroupIds = new Set(db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id));
for (const ugId of newGroupIds) {
// PATCH /multigroup/:id
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { userGroupIds } = req.body;
try {
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
if (!mg) return res.status(404).json({ error: 'Not found' });
if (!Array.isArray(userGroupIds)) return res.status(400).json({ error: 'userGroupIds required' });
const currentGroupIds = new Set((await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [mg.id])).map(r => r.user_group_id));
const newGroupSet = new Set(userGroupIds.map(Number));
for (const ugId of newGroupSet) {
if (!currentGroupIds.has(ugId)) {
db.prepare("INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)").run(mg.id, ugId);
// Add users silently — no per-user notifications in multi-group DMs
for (const uid of getUserIdsForGroup(db, ugId)) addUserSilent(db, mg.dm_group_id, uid);
const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId);
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been added to this conversation.`);
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mg.id, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) await addUserSilent(req.schema, mg.dm_group_id, uid);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A new group has joined this conversation.`);
}
}
for (const ugId of currentGroupIds) {
if (!newGroupIds.has(ugId)) {
db.prepare('DELETE FROM multi_group_dm_members WHERE multi_group_dm_id = ? AND user_group_id = ?').run(mg.id, ugId);
// Remove users silently — no per-user notifications in multi-group DMs
for (const uid of getUserIdsForGroup(db, ugId)) {
const stillIn = db.prepare('SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id = mgdm.user_group_id WHERE mgdm.multi_group_dm_id = ? AND ugm.user_id = ?').get(mg.id, uid);
if (!newGroupSet.has(ugId)) {
await exec(req.schema, 'DELETE FROM multi_group_dm_members WHERE multi_group_dm_id=$1 AND user_group_id=$2', [mg.id, ugId]);
const uids = await getUserIdsForGroup(req.schema, ugId);
for (const uid of uids) {
const stillIn = await queryOne(req.schema, `
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
`, [mg.id, uid]);
if (!stillIn) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
}
const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId);
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been removed from this conversation.`);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
}
}
}
const updated = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
updated.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id);
updated.group_count = updated.memberGroupIds.length;
res.json({ dm: updated });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
if (!mg) return res.status(404).json({ error: 'Not found' });
if (mg.dm_group_id) {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(mg.dm_group_id).map(r => r.user_id);
db.prepare('DELETE FROM groups WHERE id = ?').run(mg.dm_group_id);
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
db.prepare('DELETE FROM multi_group_dms WHERE id = ?').run(mg.id);
res.json({ success: true });
// DELETE /multigroup/:id
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
if (!mg) return res.status(404).json({ error: 'Not found' });
if (mg.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── USER GROUPS ───────────────────────────────────────────────────────────────
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const groups = db.prepare(`
SELECT ug.*,
(SELECT COUNT(*) FROM user_group_members WHERE user_group_id = ug.id) as member_count
FROM user_groups ug ORDER BY ug.name ASC
`).all();
res.json({ groups });
// GET / — list all user groups
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const groups = await query(req.schema, `
SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id=ug.id) AS member_count
FROM user_groups ug ORDER BY ug.name ASC
`);
res.json({ groups });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Not found' });
const members = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
FROM user_group_members ugm JOIN users u ON u.id = ugm.user_id
WHERE ugm.user_group_id = ? ORDER BY u.name ASC
`).all(req.params.id);
res.json({ group, members });
// GET /:id
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const group = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!group) return res.status(404).json({ error: 'Not found' });
const members = await query(req.schema, `
SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
`, [req.params.id]);
res.json({ group, members });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
// POST / — create user group
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, memberIds = [] } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
return res.status(400).json({ error: 'A group with that name already exists' });
}
// Check for duplicate member set
const newIds = [...new Set((Array.isArray(memberIds) ? memberIds : []).map(Number).filter(Boolean))].sort();
if (newIds.length > 0) {
const allGroups = db.prepare('SELECT id, name FROM user_groups').all();
for (const existing of allGroups) {
const existingIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(existing.id).map(r => r.user_id).sort();
if (existingIds.length === newIds.length && existingIds.every((id, i) => id === newIds[i])) {
return res.status(400).json({ error: `Group not created — "${existing.name}" already exists with the same members.` });
}
try {
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
if (existing) return res.status(400).json({ error: 'Name already in use' });
// Create the managed DM group
const gr = await queryResult(req.schema,
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
[name.trim()]
);
const dmGroupId = gr.rows[0].id;
const ugr = await queryResult(req.schema,
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
[name.trim(), dmGroupId]
);
const ugId = ugr.rows[0].id;
for (const uid of memberIds) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ugId, uid]);
await addUserSilent(req.schema, dmGroupId, uid);
}
}
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
const dmResult = db.prepare(`INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, is_managed) VALUES (?, 'private', ?, 0, 0, 1)`).run(name.trim(), admin?.id || req.user.id);
const dmGroupId = dmResult.lastInsertRowid;
const ugResult = db.prepare(`INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId);
const ugId = ugResult.lastInsertRowid;
for (const uid of (Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : [])) {
db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ugId, uid);
addUserSilent(db, dmGroupId, uid);
}
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(ugId);
res.json({ group });
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
res.json({ userGroup: ug });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
if (!ug) return res.status(404).json({ error: 'Not found' });
// PATCH /:id
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, memberIds } = req.body;
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
if (name && name.trim() !== ug.name) {
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id)) {
return res.status(400).json({ error: 'Name already in use' });
}
db.prepare("UPDATE user_groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.id);
if (ug.dm_group_id) db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.dm_group_id);
}
if (Array.isArray(memberIds) && ug.dm_group_id) {
const newIds = new Set(memberIds.map(Number).filter(Boolean));
const currentSet = new Set(db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(ug.id).map(r => r.user_id));
const addedUids = [];
const removedUids = [];
for (const uid of newIds) {
if (!currentSet.has(uid)) {
db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ug.id, uid);
// Add to UG DM with individual notification
addUser(db, ug.dm_group_id, uid, req.user.id);
addedUids.push(uid);
}
}
for (const uid of currentSet) {
if (!newIds.has(uid)) {
db.prepare('DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?').run(ug.id, uid);
// For managed DMs, membership is controlled solely by the user group — always remove
removeUser(db, ug.dm_group_id, uid, req.user.id);
removedUids.push(uid);
}
if (name && name.trim() !== ug.name) {
const conflict = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), ug.id]);
if (conflict) return res.status(400).json({ error: 'Name already in use' });
await exec(req.schema, 'UPDATE user_groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.id]);
if (ug.dm_group_id) await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.dm_group_id]);
}
// For multi-group DMs: add/remove users silently, post group-level notification once
const mgDms = db.prepare('SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm JOIN multi_group_dms mgd ON mgd.id = mgdm.multi_group_dm_id WHERE mgdm.user_group_id = ?').all(ug.id);
for (const mg of mgDms) {
if (!mg.dm_group_id) continue;
for (const uid of addedUids) addUserSilent(db, mg.dm_group_id, uid);
for (const uid of removedUids) {
const stillInMg = db.prepare('SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id = mgdm.user_group_id WHERE mgdm.multi_group_dm_id = ? AND ugm.user_id = ?').get(mg.id, uid);
if (!stillInMg) {
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
if (Array.isArray(memberIds) && ug.dm_group_id) {
const newIds = new Set(memberIds.map(Number).filter(Boolean));
const currentSet = new Set((await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.user_id));
const addedUids = [], removedUids = [];
for (const uid of newIds) {
if (!currentSet.has(uid)) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
await addUser(req.schema, ug.dm_group_id, uid, req.user.id);
addedUids.push(uid);
}
}
for (const uid of currentSet) {
if (!newIds.has(uid)) {
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
await removeUser(req.schema, ug.dm_group_id, uid, req.user.id);
removedUids.push(uid);
}
}
if (addedUids.length > 0) postSysMsg(db, mg.dm_group_id, req.user.id, `Members were added to group "${ug.name}" and have joined this conversation.`);
if (removedUids.length > 0) postSysMsg(db, mg.dm_group_id, req.user.id, `Members were removed from group "${ug.name}" and have left this conversation.`);
}
}
const updated = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
res.json({ group: updated });
// Propagate to multi-group DMs
const mgDms = await query(req.schema, `
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
`, [ug.id]);
for (const mg of mgDms) {
if (!mg.dm_group_id) continue;
for (const uid of addedUids) await addUserSilent(req.schema, mg.dm_group_id, uid);
for (const uid of removedUids) {
const stillIn = await queryOne(req.schema, `
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
`, [mg.id, uid]);
if (!stillIn) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
}
}
if (addedUids.length > 0) await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `Members were added to group "${ug.name}" and have joined this conversation.`);
if (removedUids.length > 0) await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `Members were removed from group "${ug.name}" and have left this conversation.`);
}
}
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
res.json({ group: updated });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
if (!ug) return res.status(404).json({ error: 'Not found' });
if (ug.dm_group_id) {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(ug.dm_group_id).map(r => r.user_id);
db.prepare('DELETE FROM groups WHERE id = ?').run(ug.dm_group_id);
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id });
}
db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id);
res.json({ success: true });
// DELETE /:id
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
if (ug.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
for (const uid of members) { io.in(`user:${uid}`).socketsLeave(`group:${ug.dm_group_id}`); io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); }
}
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;

View File

@@ -1,318 +1,264 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { query, queryOne, queryResult, exec, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const avatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
filename: (req, file, cb) => cb(null, `avatar_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`),
});
const uploadAvatar = multer({
storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
function resolveUniqueName(db, baseName, excludeId = null) {
const existing = db.prepare(
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
async function resolveUniqueName(schema, baseName, excludeId = null) {
const existing = await query(schema,
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
[excludeId ?? -1, baseName, `${baseName} (%)`]
);
if (existing.length === 0) return baseName;
let max = 0;
for (const u of existing) {
const m = u.name.match(/\((\d+)\)$/);
if (m) max = Math.max(max, parseInt(m[1]));
else max = Math.max(max, 0);
}
for (const u of existing) { const m = u.name.match(/\((\d+)\)$/); if (m) max = Math.max(max, parseInt(m[1])); else max = Math.max(max, 0); }
return `${baseName} (${max + 1})`;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
function getDefaultPassword(db) {
return process.env.USER_PASS || 'user@1234';
}
// List users (admin)
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const users = db.prepare(`
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online
FROM users WHERE status != 'deleted'
ORDER BY created_at ASC
`).all();
res.json({ users });
// List users
router.get('/', authMiddleware, adminMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
"SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Search users (public-ish for mentions/add-member)
router.get('/search', authMiddleware, (req, res) => {
// Search users
router.get('/search', authMiddleware, async (req, res) => {
const { q, groupId } = req.query;
const db = getDb();
let users;
if (groupId) {
const group = db.prepare('SELECT type, is_direct FROM groups WHERE id = ?').get(parseInt(groupId));
if (group && (group.type === 'private' || group.is_direct)) {
// Private group or direct message — only show members of this group
users = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
FROM users u
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
WHERE u.status = 'active' AND u.id != ?
AND (u.name LIKE ? OR u.display_name LIKE ?)
LIMIT 10
`).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`);
try {
let users;
if (groupId) {
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
if (group && (group.type === 'private' || group.is_direct)) {
users = await query(req.schema,
"SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
[parseInt(groupId), req.user.id, `%${q}%`]
);
} else {
users = await query(req.schema,
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) LIMIT 10",
[req.user.id, `%${q}%`]
);
}
} else {
// Public group — all active users
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(req.user.id, `%${q}%`, `%${q}%`);
users = await query(req.schema,
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) LIMIT 10",
[`%${q}%`]
);
}
} else {
users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
}
res.json({ users });
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Check if a display name is already taken (excludes self)
router.get('/check-display-name', authMiddleware, (req, res) => {
// Check display name
router.get('/check-display-name', authMiddleware, async (req, res) => {
const { name } = req.query;
if (!name) return res.json({ taken: false });
const db = getDb();
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(name, req.user.id);
res.json({ taken: !!conflict });
try {
const conflict = await queryOne(req.schema,
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
[name, req.user.id]
);
res.json({ taken: !!conflict });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
// Create user
router.post('/', authMiddleware, adminMiddleware, async (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
const db = getDb();
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = resolveUniqueName(db, name.trim());
const pw = (password || '').trim() || getDefaultPassword(db);
const hash = bcrypt.hashSync(pw, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid);
// Admin users are automatically added to the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid);
try {
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email = $1', [email]);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name.trim());
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (role === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
}
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.json({ user });
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Bulk create users
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
// Bulk create
router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
const { users } = req.body;
const db = getDb();
const results = { created: [], skipped: [] };
const seenEmails = new Set();
const defaultPw = getDefaultPassword(db);
const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`);
for (const u of users) {
const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email);
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try {
const resolvedName = resolveUniqueName(db, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = insertUser.run(resolvedName, email, hash, newRole);
addUserToPublicGroups(r.lastInsertRowid);
if (newRole === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
const defaultPw = process.env.USER_PASS || 'user@1234';
try {
for (const u of users) {
const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email);
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email=$1', [email]);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try {
const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
[resolvedName, email, hash, newRole]
);
await addUserToPublicGroups(req.schema, r.rows[0].id);
if (newRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, r.rows[0].id]);
}
}
results.created.push(email);
} catch (e) {
results.skipped.push({ email, reason: e.message });
results.created.push(email);
} catch (e) { results.skipped.push({ email, reason: e.message }); }
}
}
res.json(results);
res.json(results);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user name (admin only — req 5)
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
// Patch name
router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
// Pass the target's own id so their current name is excluded from the duplicate check
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
res.json({ success: true, name: resolvedName });
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
const resolvedName = await resolveUniqueName(req.schema, name.trim(), req.params.id);
await exec(req.schema, 'UPDATE users SET name=$1, updated_at=NOW() WHERE id=$2', [resolvedName, target.id]);
res.json({ success: true, name: resolvedName });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
// Patch role
router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
const { role } = req.body;
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
// If promoted to admin, ensure they're in the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
await exec(req.schema, 'UPDATE users SET role=$1, updated_at=NOW() WHERE id=$2', [role, target.id]);
if (role === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, target.id]);
}
}
res.json({ success: true });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Reset user password (admin)
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
// Reset password
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
const db = getDb();
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id);
res.json({ success: true });
try {
const hash = bcrypt.hashSync(password, 10);
await exec(req.schema, 'UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE id=$2', [hash, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Suspend user (admin)
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
// Suspend / activate / delete
router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) => {
try {
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!t) return res.status(404).json({ error: 'User not found' });
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/:id/activate', authMiddleware, adminMiddleware, async (req, res) => {
try {
await exec(req.schema, "UPDATE users SET status='active', updated_at=NOW() WHERE id=$1", [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) => {
try {
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!t) return res.status(404).json({ error: 'User not found' });
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
await exec(req.schema, "UPDATE users SET status='deleted', updated_at=NOW() WHERE id=$1", [t.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Activate user (admin)
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
// Delete user (admin)
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
// Update own profile
router.patch('/me/profile', authMiddleware, async (req, res) => {
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const db = getDb();
if (displayName) {
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(displayName, req.user.id);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
try {
if (displayName) {
const conflict = await queryOne(req.schema,
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
[displayName, req.user.id]
);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
await exec(req.schema,
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
);
const user = await queryOne(req.schema,
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
[req.user.id]
);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload avatar — resize if needed, skip compression for files under 500 KB
// Upload avatar
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const sharp = require('sharp');
const sharp = require('sharp');
const filePath = req.file.path;
const fileSizeBytes = req.file.size;
const FIVE_HUNDRED_KB = 500 * 1024;
const MAX_DIM = 256; // max width/height in pixels
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
// Small enough and already correctly sized — serve as-is
} else {
// Resize (and compress only if over 500 KB)
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
// Compress: use webp for best size/quality ratio
pipeline = pipeline.webp({ quality: 82 });
await pipeline.toFile(outPath + '.webp');
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp'));
const newPath = filePath.replace(/\.[^.]+$/, '.webp');
const newFilename = path.basename(newPath);
const db = getDb();
const avatarUrl = `/uploads/avatars/${newFilename}`;
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
return res.json({ avatarUrl });
} else {
// Under 500 KB but needs resize — resize only, keep original format
await pipeline.toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath, filePath);
}
const MAX_DIM = 256;
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
if (req.file.size >= 500 * 1024 || needsResize) {
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
const avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
return res.json({ avatarUrl });
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
res.json({ avatarUrl });
} catch (err) {
console.error('Avatar processing error:', err);
// Fall back to serving unprocessed file
console.error('Avatar error:', err);
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]).catch(()=>{});
res.json({ avatarUrl });
}
});

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.9.87}"
VERSION="${1:-0.10.1}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

97
docker-compose.host.yaml Normal file
View File

@@ -0,0 +1,97 @@
# docker-compose.host.yaml — JAMA-HOST multi-tenant deployment
#
# Use this instead of docker-compose.yaml when running JAMA-HOST.
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
#
# Usage:
# docker compose -f docker-compose.host.yaml up -d
#
# Required .env additions for host mode:
# APP_TYPE=host
# HOST_DOMAIN=jamachat.com
# HOST_ADMIN_KEY=your_secret_host_admin_key
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
services:
jama:
image: jama:${JAMA_VERSION:-latest}
container_name: ${PROJECT_NAME:-jama}
restart: unless-stopped
# No direct port exposure — traffic comes through Caddy
expose:
- "3000"
environment:
- NODE_ENV=production
- TZ=${TZ:-UTC}
- APP_TYPE=host
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- APP_NAME=${APP_NAME:-jama}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME:-jama}
- DB_USER=${DB_USER:-jama}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
volumes:
- jama_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
container_name: ${PROJECT_NAME:-jama}_db
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME:-jama}
- POSTGRES_USER=${DB_USER:-jama}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- jama_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
interval: 5s
timeout: 5s
retries: 10
caddy:
# Use a Caddy build with your DNS provider plugin.
# Pre-built images: https://github.com/abiosoft/caddy-docker
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
image: caddy:2-alpine
container_name: ${PROJECT_NAME:-jama}_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
- CF_API_TOKEN=${CF_API_TOKEN:-} # DNS provider token for wildcard certs
volumes:
- ./Caddyfile.example:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- /var/log/caddy:/var/log/caddy
depends_on:
- jama
volumes:
jama_db:
driver: local
jama_uploads:
driver: local
caddy_data:
driver: local
caddy_config:
driver: local

View File

@@ -8,24 +8,48 @@ services:
environment:
- NODE_ENV=production
- TZ=${TZ:-UTC}
- APP_TYPE=${APP_TYPE:-selfhost}
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- USER_PASS=${USER_PASS:-user@1234}
- ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- DB_KEY=${DB_KEY}
- APP_NAME=${APP_NAME:-jama}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME:-jama}
- DB_USER=${DB_USER:-jama}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:-}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
volumes:
- jama_db:/app/data
- jama_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
container_name: ${PROJECT_NAME:-jama}_db
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME:-jama}
- POSTGRES_USER=${DB_USER:-jama}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- jama_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
jama_db:
driver: local

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.87",
"version": "0.10.1",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -5,6 +5,7 @@ import { ToastProvider } from './contexts/ToastContext.jsx';
import Login from './pages/Login.jsx';
import Chat from './pages/Chat.jsx';
import ChangePassword from './pages/ChangePassword.jsx';
import HostAdmin from './pages/HostAdmin.jsx';
function ProtectedRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }) {
function AuthRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
// Always show login in light mode regardless of user's saved theme preference
document.documentElement.setAttribute('data-theme', 'light');
if (loading) return null;
if (user && !mustChangePassword) return <Navigate to="/" replace />;
@@ -28,7 +28,6 @@ function AuthRoute({ children }) {
}
function RestoreTheme() {
// Called when entering a protected route — restore the user's saved theme
const saved = localStorage.getItem('jama-theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
return null;
@@ -38,16 +37,24 @@ export default function App() {
return (
<BrowserRouter>
<ToastProvider>
<AuthProvider>
<SocketProvider>
<Routes>
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SocketProvider>
</AuthProvider>
<Routes>
{/* /host renders outside AuthProvider — has its own key-based auth */}
<Route path="/host" element={<HostAdmin />} />
<Route path="/host/*" element={<HostAdmin />} />
{/* All other routes go through jama auth */}
<Route path="/*" element={
<AuthProvider>
<SocketProvider>
<Routes>
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SocketProvider>
</AuthProvider>
} />
</Routes>
</ToastProvider>
</BrowserRouter>
);

View File

@@ -62,7 +62,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
{/* User section */}
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
{item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
{features.scheduleManager && item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
{/* Admin section */}
{isAdmin && (

View File

@@ -221,8 +221,13 @@ export default function UserManagerModal({ onClose }) {
const fileRef = useRef(null);
const [userPass, setUserPass] = useState('user@1234');
const [loadError, setLoadError] = useState('');
const load = () => {
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
setLoadError('');
api.getUsers()
.then(({ users }) => setUsers(users))
.catch(e => setLoadError(e.message || 'Failed to load users'))
.finally(() => setLoading(false));
};
useEffect(() => {
load();
@@ -305,6 +310,11 @@ export default function UserManagerModal({ onClose }) {
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
{loading ? (
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
) : loadError ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--error)' }}>
<div style={{ marginBottom: 10 }}> {loadError}</div>
<button className="btn btn-secondary btn-sm" onClick={() => { setLoading(true); load(); }}>Retry</button>
</div>
) : (
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{filtered.map(u => (

View File

@@ -0,0 +1,602 @@
import { useState, useEffect, useCallback } from 'react';
// ── Constants ─────────────────────────────────────────────────────────────────
const PLANS = [
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
];
const PLAN_BADGE = {
chat: { bg: '#e8f0fe', color: '#1a73e8', label: 'Chat' },
brand: { bg: '#fce8b2', color: '#e37400', label: 'Brand' },
team: { bg: '#e6f4ea', color: '#188038', label: 'Team' },
};
const STATUS_BADGE = {
active: { bg: '#e6f4ea', color: '#188038' },
suspended: { bg: '#fce8b2', color: '#e37400' },
};
// ── API helpers ───────────────────────────────────────────────────────────────
function useHostApi(adminKey) {
const call = useCallback(async (method, path, body) => {
const res = await fetch(`/api/host${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Host-Admin-Key': adminKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}, [adminKey]);
return {
getStatus: () => call('GET', '/status'),
getTenants: () => call('GET', '/tenants'),
createTenant: (body) => call('POST', '/tenants', body),
updateTenant: (slug, b) => call('PATCH', `/tenants/${slug}`, b),
deleteTenant: (slug) => call('DELETE', `/tenants/${slug}`, { confirm: `DELETE ${slug}` }),
suspendTenant:(slug) => call('PATCH', `/tenants/${slug}`, { status: 'suspended' }),
activateTenant:(slug) => call('PATCH', `/tenants/${slug}`, { status: 'active' }),
migrateAll: () => call('POST', '/migrate-all'),
};
}
// ── Small reusable components ─────────────────────────────────────────────────
function Badge({ value, map }) {
const s = map[value] || { bg: '#f1f3f4', color: '#5f6368' };
return (
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: s.bg, color: s.color, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
{s.label || value}
</span>
);
}
function Btn({ onClick, children, variant = 'secondary', size = 'md', disabled, style = {} }) {
const base = {
border: 'none', borderRadius: 6, cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6,
opacity: disabled ? 0.5 : 1, transition: 'opacity 0.15s',
padding: size === 'sm' ? '5px 12px' : '9px 18px',
fontSize: size === 'sm' ? 12 : 14,
};
const variants = {
primary: { background: '#1a73e8', color: '#fff' },
danger: { background: '#d93025', color: '#fff' },
warning: { background: '#e37400', color: '#fff' },
success: { background: '#188038', color: '#fff' },
secondary:{ background: '#f1f3f4', color: '#202124' },
ghost: { background: 'transparent', color: '#5f6368', padding: size === 'sm' ? '4px 8px' : '8px 12px' },
};
return (
<button onClick={onClick} disabled={disabled} style={{ ...base, ...variants[variant], ...style }}>
{children}
</button>
);
}
function Input({ label, value, onChange, placeholder, type = 'text', required, hint, autoComplete }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{label && (
<label style={{ fontSize: 12, fontWeight: 600, color: '#5f6368' }}>
{label}{required && <span style={{ color: '#d93025', marginLeft: 2 }}>*</span>}
</label>
)}
<input
type={type} value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} required={required}
autoComplete={autoComplete || 'new-password'} autoCorrect="off" spellCheck={false}
style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
fontSize: 14, outline: 'none', background: '#fff', color: '#202124',
transition: 'border-color 0.15s' }}
onFocus={e => e.target.style.borderColor = '#1a73e8'}
onBlur={e => e.target.style.borderColor = '#e0e0e0'}
/>
{hint && <span style={{ fontSize: 11, color: '#9aa0a6' }}>{hint}</span>}
</div>
);
}
function Select({ label, value, onChange, options, required }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{label && <label style={{ fontSize: 12, fontWeight: 600, color: '#5f6368' }}>{label}{required && <span style={{ color: '#d93025', marginLeft: 2 }}>*</span>}</label>}
<select value={value} onChange={e => onChange(e.target.value)}
style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
fontSize: 14, outline: 'none', background: '#fff', color: '#202124' }}>
{options.map(o => <option key={o.value} value={o.value}>{o.label}{o.desc ? `${o.desc}` : ''}</option>)}
</select>
</div>
);
}
function Modal({ title, onClose, children, width = 480 }) {
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: '#fff', borderRadius: 12, width: '100%', maxWidth: width,
maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 24px', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ fontWeight: 700, fontSize: 16 }}>{title}</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer',
fontSize: 20, color: '#9aa0a6', lineHeight: 1, padding: 4 }}></button>
</div>
<div style={{ padding: 24 }}>{children}</div>
</div>
</div>
);
}
function Toast({ toasts }) {
return (
<div style={{ position: 'fixed', bottom: 24, right: 24, display: 'flex', flexDirection: 'column',
gap: 8, zIndex: 2000 }}>
{toasts.map(t => (
<div key={t.id} style={{ padding: '12px 18px', borderRadius: 8, fontSize: 13, fontWeight: 500,
background: t.type === 'error' ? '#d93025' : t.type === 'warning' ? '#e37400' : '#188038',
color: '#fff', boxShadow: '0 2px 8px rgba(0,0,0,0.2)', maxWidth: 360 }}>
{t.msg}
</div>
))}
</div>
);
}
// ── Provision tenant modal ─────────────────────────────────────────────────────
function ProvisionModal({ api, baseDomain, onClose, onDone }) {
const [form, setForm] = useState({
slug: '', name: '', plan: 'chat',
adminEmail: '', adminName: 'Admin User', adminPass: '',
customDomain: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const set = k => v => setForm(f => ({ ...f, [k]: v }));
const handle = async () => {
if (!form.slug || !form.name) return setError('Slug and name are required');
setSaving(true); setError('');
try {
const { tenant } = await api.createTenant({
slug: form.slug.toLowerCase().trim(),
name: form.name.trim(),
plan: form.plan,
adminEmail: form.adminEmail || undefined,
adminName: form.adminName || undefined,
adminPass: form.adminPass || undefined,
customDomain: form.customDomain || undefined,
});
onDone(tenant);
} catch (e) { setError(e.message); }
finally { setSaving(false); }
};
const preview = form.slug ? `${form.slug.toLowerCase()}.${baseDomain}` : '';
return (
<Modal title="Provision New Tenant" onClose={onClose} width={520}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <div style={{ padding: '10px 14px', background: '#fce8e6', color: '#d93025',
borderRadius: 6, fontSize: 13 }}>{error}</div>}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Input label="Slug" value={form.slug} onChange={set('slug')} required
placeholder="team-alpha"
hint={preview ? `URL: ${preview}` : 'Used as subdomain + schema name'} />
<Input label="Display Name" value={form.name} onChange={set('name')} required placeholder="Team Alpha" />
</div>
<Select label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} required />
<div style={{ borderTop: '1px solid #e0e0e0', paddingTop: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9aa0a6', textTransform: 'uppercase',
letterSpacing: '0.5px', marginBottom: 12 }}>First Admin User (optional defaults to .env values)</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Input label="Admin Email" value={form.adminEmail} onChange={set('adminEmail')}
placeholder="admin@teamalpha.com" type="email" />
<Input label="Admin Name" value={form.adminName} onChange={set('adminName')}
placeholder="Admin User" />
<Input label="Temp Password" value={form.adminPass} onChange={set('adminPass')}
placeholder="Auto-generated if blank" type="text" />
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', paddingTop: 12 }}>
<Input label="Custom Domain (optional)" value={form.customDomain} onChange={set('customDomain')}
placeholder="chat.teamalpha.com"
hint="Tenant can also be reached at this domain once DNS is configured" />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4 }}>
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
<Btn onClick={handle} variant="primary" disabled={saving}>
{saving ? 'Provisioning…' : '✦ Provision Tenant'}
</Btn>
</div>
</div>
</Modal>
);
}
// ── Edit tenant modal ──────────────────────────────────────────────────────────
function EditModal({ api, tenant, onClose, onDone }) {
const [form, setForm] = useState({
name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const set = k => v => setForm(f => ({ ...f, [k]: v }));
const handle = async () => {
setSaving(true); setError('');
try {
const { tenant: updated } = await api.updateTenant(tenant.slug, {
name: form.name || undefined,
plan: form.plan,
customDomain: form.customDomain || null,
});
onDone(updated);
} catch (e) { setError(e.message); }
finally { setSaving(false); }
};
return (
<Modal title={`Edit — ${tenant.slug}`} onClose={onClose}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <div style={{ padding: '10px 14px', background: '#fce8e6', color: '#d93025',
borderRadius: 6, fontSize: 13 }}>{error}</div>}
<Input label="Display Name" value={form.name} onChange={set('name')} required />
<Select label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
<Input label="Custom Domain" value={form.customDomain} onChange={set('customDomain')}
placeholder="chat.example.com" hint="Leave blank to remove custom domain" />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
<Btn onClick={handle} variant="primary" disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</Btn>
</div>
</div>
</Modal>
);
}
// ── Delete confirmation modal ──────────────────────────────────────────────────
function DeleteModal({ api, tenant, onClose, onDone }) {
const [confirm, setConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState('');
const expected = `DELETE ${tenant.slug}`;
const handle = async () => {
setDeleting(true); setError('');
try {
await api.deleteTenant(tenant.slug);
onDone(tenant.slug);
} catch (e) { setError(e.message); }
finally { setDeleting(false); }
};
return (
<Modal title="Delete Tenant" onClose={onClose}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', background: '#fce8e6', borderRadius: 8, fontSize: 13, color: '#d93025' }}>
<strong>This is permanent.</strong> The tenant's Postgres schema and all data —
messages, events, users, uploads — will be deleted and cannot be recovered.
</div>
<div style={{ fontSize: 14, color: '#202124' }}>
To confirm, type <code style={{ background: '#f1f3f4', padding: '2px 6px',
borderRadius: 4, fontFamily: 'monospace' }}>{expected}</code> below:
</div>
{error && <div style={{ color: '#d93025', fontSize: 13 }}>{error}</div>}
<Input value={confirm} onChange={setConfirm} placeholder={expected} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
<Btn onClick={handle} variant="danger" disabled={confirm !== expected || deleting}>
{deleting ? 'Deleting' : 'Permanently Delete'}
</Btn>
</div>
</div>
</Modal>
);
}
// ── Tenant row ────────────────────────────────────────────────────────────────
function TenantRow({ tenant, baseDomain, api, onRefresh, onToast }) {
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false);
const subdomainUrl = `https://${tenant.slug}.${baseDomain}`;
const url = tenant.custom_domain ? `https://${tenant.custom_domain}` : subdomainUrl;
const toggleStatus = async () => {
setBusy(true);
try {
if (tenant.status === 'active') await api.suspendTenant(tenant.slug);
else await api.activateTenant(tenant.slug);
onRefresh();
onToast(`Tenant ${tenant.slug} ${tenant.status === 'active' ? 'suspended' : 'activated'}`, 'success');
} catch (e) { onToast(e.message, 'error'); }
finally { setBusy(false); }
};
return (
<>
<tr style={{ borderBottom: '1px solid #e0e0e0' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{tenant.name}</div>
<div style={{ fontSize: 12, color: '#9aa0a6', fontFamily: 'monospace' }}>{tenant.slug}</div>
</td>
<td style={{ padding: '12px 16px' }}>
<Badge value={tenant.plan} map={PLAN_BADGE} />
</td>
<td style={{ padding: '12px 16px' }}>
<Badge value={tenant.status} map={STATUS_BADGE} />
</td>
<td style={{ padding: '12px 16px' }}>
<a href={url} target="_blank" rel="noreferrer"
style={{ fontSize: 12, color: '#1a73e8', textDecoration: 'none' }}>
{url} ↗
</a>
{tenant.custom_domain && (
<div style={{ fontSize: 11, color: '#9aa0a6' }}>{subdomainUrl}</div>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: '#9aa0a6', whiteSpace: 'nowrap' }}>
{new Date(tenant.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn size="sm" variant="ghost" onClick={() => setEditing(true)}>Edit</Btn>
<Btn size="sm" variant={tenant.status === 'active' ? 'warning' : 'success'}
onClick={toggleStatus} disabled={busy}>
{busy ? '' : tenant.status === 'active' ? 'Suspend' : 'Activate'}
</Btn>
<Btn size="sm" variant="danger" onClick={() => setDeleting(true)}>Delete</Btn>
</div>
</td>
</tr>
{editing && (
<EditModal api={api} tenant={tenant} onClose={() => setEditing(false)}
onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated', 'success'); }} />
)}
{deleting && (
<DeleteModal api={api} tenant={tenant} onClose={() => setDeleting(false)}
onDone={() => { setDeleting(false); onRefresh(); onToast('Tenant deleted', 'success'); }} />
)}
</>
);
}
// ── Key entry screen ──────────────────────────────────────────────────────────
function KeyEntry({ onSubmit }) {
const [key, setKey] = useState('');
const [error, setError] = useState('');
const handle = async () => {
if (!key.trim()) return setError('Admin key required');
setError('');
const res = await fetch('/api/host/status', {
headers: { 'X-Host-Admin-Key': key.trim() },
});
if (res.ok) {
sessionStorage.setItem('jama-host-key', key.trim());
onSubmit(key.trim());
} else {
setError('Invalid admin key');
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#f1f3f4' }}>
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>JAMA-HOST</h1>
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
<input
type="password" value={key} onChange={e => setKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handle()}
placeholder="Host admin key" autoFocus
style={{ width: '100%', padding: '10px 12px', border: '1px solid #e0e0e0', borderRadius: 6,
fontSize: 14, outline: 'none', boxSizing: 'border-box', marginBottom: 12 }}
/>
<Btn onClick={handle} variant="primary" style={{ width: '100%', justifyContent: 'center' }}>
Sign In
</Btn>
</div>
</div>
);
}
// ── Main host admin panel ─────────────────────────────────────────────────────
export default function HostAdmin() {
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
const [status, setStatus] = useState(null);
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [migrating, setMigrating] = useState(false);
const [toasts, setToasts] = useState([]);
const [search, setSearch] = useState('');
const api = useHostApi(adminKey);
const toast = useCallback((msg, type = 'success') => {
const id = Date.now();
setToasts(t => [...t, { id, msg, type }]);
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000);
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const [s, t] = await Promise.all([api.getStatus(), api.getTenants()]);
setStatus(s);
setTenants(t.tenants);
} catch (e) {
toast(e.message, 'error');
if (e.message.includes('Invalid') || e.message.includes('401')) {
sessionStorage.removeItem('jama-host-key');
setAdminKey('');
}
} finally { setLoading(false); }
}, [api, toast]);
useEffect(() => { if (adminKey) load(); }, [adminKey]);
const handleMigrateAll = async () => {
setMigrating(true);
try {
const { results } = await api.migrateAll();
const errors = results.filter(r => r.status === 'error');
if (errors.length) toast(`${errors.length} migration(s) failed — check logs`, 'error');
else toast(`Migrations applied to ${results.length} tenant(s)`, 'success');
} catch (e) { toast(e.message, 'error'); }
finally { setMigrating(false); }
};
if (!adminKey) return <KeyEntry onSubmit={setAdminKey} />;
const filtered = tenants.filter(t =>
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
t.slug.toLowerCase().includes(search.toLowerCase())
);
const baseDomain = status?.baseDomain || 'jamachat.com';
return (
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
{/* Header */}
<div style={{ background: '#1a73e8', color: '#fff', padding: '0 24px' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', height: 56 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 20 }}>🏠</span>
<span style={{ fontWeight: 700, fontSize: 16 }}>JAMA-HOST</span>
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{status && (
<span style={{ fontSize: 12, opacity: 0.85 }}>
{status.tenants.active} active · {status.tenants.total} total
</span>
)}
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
Sign Out
</Btn>
</div>
</div>
</div>
{/* Main */}
<div style={{ maxWidth: 1100, margin: '0 auto', padding: 24 }}>
{/* Stat cards */}
{status && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[
{ label: 'Total Tenants', value: status.tenants.total, color: '#1a73e8' },
{ label: 'Active', value: status.tenants.active, color: '#188038' },
{ label: 'Suspended', value: status.tenants.total - status.tenants.active, color: '#e37400' },
{ label: 'Mode', value: status.appType, color: '#5f6368' },
].map(s => (
<div key={s.label} style={{ background: '#fff', borderRadius: 10, padding: '16px 20px',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 24, fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 12, color: '#9aa0a6', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Toolbar */}
<div style={{ background: '#fff', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #e0e0e0', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>Tenants</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search tenants…" autoComplete="off"
style={{ padding: '7px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
fontSize: 13, outline: 'none', width: 200 }} />
<Btn size="sm" variant="secondary" onClick={load} disabled={loading}>
{loading ? '' : ' Refresh'}
</Btn>
<Btn size="sm" variant="secondary" onClick={handleMigrateAll} disabled={migrating}>
{migrating ? 'Migrating' : ' Migrate All'}
</Btn>
<Btn size="sm" variant="primary" onClick={() => setProvisioning(true)}>
✦ New Tenant
</Btn>
</div>
</div>
{/* Table */}
{loading && tenants.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#9aa0a6' }}>Loading…</div>
) : filtered.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#9aa0a6' }}>
{search ? 'No tenants match your search.' : 'No tenants yet. Provision your first one!'}
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
{['Tenant', 'Plan', 'Status', 'URL', 'Created', 'Actions'].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: h === 'Actions' ? 'right' : 'left',
fontSize: 11, fontWeight: 700, color: '#9aa0a6', textTransform: 'uppercase',
letterSpacing: '0.5px', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(t => (
<TenantRow key={t.slug} tenant={t} baseDomain={baseDomain}
api={api} onRefresh={load} onToast={toast} />
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
JAMA-HOST Control Plane · {baseDomain}
</div>
</div>
{/* Provision modal */}
{provisioning && (
<ProvisionModal api={api} baseDomain={baseDomain} onClose={() => setProvisioning(false)}
onDone={tenant => {
setProvisioning(false);
load();
toast(`Tenant '${tenant.slug}' provisioned at https://${tenant.slug}.${baseDomain}`, 'success');
}} />
)}
<Toast toasts={toasts} />
</div>
);
}