diff --git a/.env.example b/.env.example index 26de08a..1a3032f 100644 --- a/.env.example +++ b/.env.example @@ -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 _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 diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..5d5bd55 --- /dev/null +++ b/Caddyfile.example @@ -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 +# } diff --git a/Dockerfile b/Dockerfile index 31efb4b..8505e2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json index 1c72591..c1dcecc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/src/index.js b/backend/src/index.js index 1ee2015..97f3e0b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 -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 }; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7a7603f..ef8ff76 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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, +}; diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 093d4f9..1617de9 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -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 (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, +}; diff --git a/backend/src/models/migrations/001_initial_schema.sql b/backend/src/models/migrations/001_initial_schema.sql new file mode 100644 index 0000000..bfe3c0f --- /dev/null +++ b/backend/src/models/migrations/001_initial_schema.sql @@ -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); diff --git a/backend/src/models/migrations/002_triggers_and_indexes.sql b/backend/src/models/migrations/002_triggers_and_indexes.sql new file mode 100644 index 0000000..0f4cc3a --- /dev/null +++ b/backend/src/models/migrations/002_triggers_and_indexes.sql @@ -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); diff --git a/backend/src/models/migrations/003_tenants.sql b/backend/src/models/migrations/003_tenants.sql new file mode 100644 index 0000000..af67c57 --- /dev/null +++ b/backend/src/models/migrations/003_tenants.sql @@ -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 $$; diff --git a/backend/src/models/migrations/004_host_plan.sql b/backend/src/models/migrations/004_host_plan.sql new file mode 100644 index 0000000..71cae55 --- /dev/null +++ b/backend/src/models/migrations/004_host_plan.sql @@ -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; diff --git a/backend/src/models/migrations/MIGRATIONS.md b/backend/src/models/migrations/MIGRATIONS.md new file mode 100644 index 0000000..a35df8c --- /dev/null +++ b/backend/src/models/migrations/MIGRATIONS.md @@ -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` diff --git a/backend/src/routes/about.js b/backend/src/routes/about.js index 3f77aeb..a41e4f3 100644 --- a/backend/src/routes/about.js +++ b/backend/src/routes/about.js @@ -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', diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 388b0e7..0637764 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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; }; diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 672766d..6c223bf 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -1,450 +1,318 @@ 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; -}; \ No newline at end of file +}; diff --git a/backend/src/routes/help.js b/backend/src/routes/help.js index 0e30bce..91d3414 100644 --- a/backend/src/routes/help.js +++ b/backend/src/routes/help.js @@ -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; diff --git a/backend/src/routes/host.js b/backend/src/routes/host.js new file mode 100644 index 0000000..c53ab8b --- /dev/null +++ b/backend/src/routes/host.js @@ -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; diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index 4b7f9fe..44d08ae 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -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; }; diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js index 76010ab..8483770 100644 --- a/backend/src/routes/push.js +++ b/backend/src/routes/push.js @@ -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 }; diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index c6aa0a6..b1e3d5e 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -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; diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 7dfd115..de0a2fc 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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; diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index 2d2b148..46be700 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -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; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 98757dd..ee8440a 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -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 }); } }); diff --git a/build.sh b/build.sh index b5f6bf8..e19a2ea 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.87}" +VERSION="${1:-0.10.1}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/docker-compose.host.yaml b/docker-compose.host.yaml new file mode 100644 index 0000000..71d2b33 --- /dev/null +++ b/docker-compose.host.yaml @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 6917806..e6f6b9d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 5313869..5ed0688 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.87", + "version": "0.10.1", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d9a022c..394bf11 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ; @@ -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 ( - - - - } /> - } /> - } /> - } /> - - - + + {/* /host renders outside AuthProvider — has its own key-based auth */} + } /> + } /> + {/* All other routes go through jama auth */} + + + + } /> + } /> + } /> + } /> + + + + } /> + ); diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index 4e04bee..eac8ebb 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -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 && ( diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index a873f0b..984ea07 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -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 }) { setSearch(e.target.value)} /> {loading ? (
+ ) : loadError ? ( +
+
⚠ {loadError}
+ +
) : (
{filtered.map(u => ( diff --git a/frontend/src/pages/HostAdmin.jsx b/frontend/src/pages/HostAdmin.jsx new file mode 100644 index 0000000..fa89022 --- /dev/null +++ b/frontend/src/pages/HostAdmin.jsx @@ -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 ( + + {s.label || value} + + ); +} + +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 ( + + ); +} + +function Input({ label, value, onChange, placeholder, type = 'text', required, hint, autoComplete }) { + return ( +
+ {label && ( + + )} + 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 && {hint}} +
+ ); +} + +function Select({ label, value, onChange, options, required }) { + return ( +
+ {label && } + +
+ ); +} + +function Modal({ title, onClose, children, width = 480 }) { + return ( +
e.target === e.currentTarget && onClose()}> +
+
+ {title} + +
+
{children}
+
+
+ ); +} + +function Toast({ toasts }) { + return ( +
+ {toasts.map(t => ( +
+ {t.msg} +
+ ))} +
+ ); +} + +// ── 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 ( + +
+ {error &&
{error}
} + +
+ + +
+ + + + +
+
+ +
+ +
+ +
+ Cancel + + {saving ? 'Provisioning…' : '✦ Provision Tenant'} + +
+
+ + ); +} + +// ── 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 ( + +
+ {error &&
{error}
} + + +
+ Cancel + {saving ? 'Saving…' : 'Save Changes'} +
+
+
+ ); +} + +// ── 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 ( + +
+
+ This is permanent. The tenant's Postgres schema and all data — + messages, events, users, uploads — will be deleted and cannot be recovered. +
+
+ To confirm, type {expected} below: +
+ {error &&
{error}
} + +
+ Cancel + + {deleting ? 'Deleting…' : 'Permanently Delete'} + +
+
+
+ ); +} + +// ── 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 ( + <> + + +
{tenant.name}
+
{tenant.slug}
+ + + + + + + + + + {url} ↗ + + {tenant.custom_domain && ( +
{subdomainUrl}
+ )} + + + {new Date(tenant.created_at).toLocaleDateString()} + + +
+ setEditing(true)}>Edit + + {busy ? '…' : tenant.status === 'active' ? 'Suspend' : 'Activate'} + + setDeleting(true)}>Delete +
+ + + + {editing && ( + setEditing(false)} + onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated', 'success'); }} /> + )} + {deleting && ( + 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 ( +
+
+
🏠
+

JAMA-HOST

+

Host Administration Panel

+ {error &&
{error}
} + 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 }} + /> + + Sign In + +
+
+ ); +} + +// ── 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 ; + + 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 ( +
+ {/* Header */} +
+
+
+ 🏠 + JAMA-HOST + / {baseDomain} +
+
+ {status && ( + + {status.tenants.active} active · {status.tenants.total} total + + )} + { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}> + Sign Out + +
+
+
+ + {/* Main */} +
+ + {/* Stat cards */} + {status && ( +
+ {[ + { 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 => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {/* Toolbar */} +
+
+
Tenants
+
+ 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 }} /> + + {loading ? '…' : '↻ Refresh'} + + + {migrating ? 'Migrating…' : '⬆ Migrate All'} + + setProvisioning(true)}> + ✦ New Tenant + +
+
+ + {/* Table */} + {loading && tenants.length === 0 ? ( +
Loading…
+ ) : filtered.length === 0 ? ( +
+ {search ? 'No tenants match your search.' : 'No tenants yet. Provision your first one!'} +
+ ) : ( +
+ + + + {['Tenant', 'Plan', 'Status', 'URL', 'Created', 'Actions'].map(h => ( + + ))} + + + + {filtered.map(t => ( + + ))} + +
{h}
+
+ )} +
+ + {/* Footer */} +
+ JAMA-HOST Control Plane · {baseDomain} +
+
+ + {/* Provision modal */} + {provisioning && ( + setProvisioning(false)} + onDone={tenant => { + setProvisioning(false); + load(); + toast(`Tenant '${tenant.slug}' provisioned at https://${tenant.slug}.${baseDomain}`, 'success'); + }} /> + )} + + +
+ ); +}