v0.9.88 major change sqlite to postgres
This commit is contained in:
66
.env.example
66
.env.example
@@ -1,56 +1,30 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ── Required ──────────────────────────────────────────────────────────────────
|
||||||
# jama — Configuration
|
DB_PASSWORD=change_me_strong_password
|
||||||
# just another messaging app
|
JWT_SECRET=change_me_super_secret_jwt_key
|
||||||
#
|
|
||||||
# Copy this file to .env and customize before first run.
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Project name — used as the Docker container name.
|
# ── App identity ──────────────────────────────────────────────────────────────
|
||||||
# If you run multiple jama instances on the same host, give each a unique name.
|
|
||||||
PROJECT_NAME=jama
|
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
|
APP_NAME=jama
|
||||||
|
|
||||||
# Default public group name (created on first run only)
|
|
||||||
DEFCHAT_NAME=General Chat
|
DEFCHAT_NAME=General Chat
|
||||||
|
|
||||||
# ── Admin credentials (used on FIRST RUN only) ────────────────
|
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
ADMIN_EMAIL=admin@jama.local
|
ADMIN_EMAIL=admin@jama.local
|
||||||
ADMIN_PASS=Admin@1234
|
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
|
ADMPW_RESET=false
|
||||||
|
|
||||||
# ── Security ──────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
# JWT secret — change this to a long random string in production!
|
DB_NAME=jama
|
||||||
# Generate one: openssl rand -hex 32
|
DB_USER=jama
|
||||||
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production
|
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
||||||
|
|
||||||
# Database encryption key (SQLCipher AES-256)
|
# ── Tenancy mode ──────────────────────────────────────────────────────────────
|
||||||
# Generate a strong key: openssl rand -hex 32
|
# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM)
|
||||||
# Leave blank to run without encryption (not recommended for production).
|
# host = multi-tenant (JAMA-HOST only)
|
||||||
#
|
APP_TYPE=selfhost
|
||||||
# IMPORTANT — upgrading an existing unencrypted install:
|
|
||||||
# 1. docker compose down
|
# ── JAMA-HOST only (ignored in selfhost mode) ─────────────────────────────────
|
||||||
# 2. Find your DB: docker volume inspect <project>_jama_db
|
# HOST_DOMAIN=jamachat.com
|
||||||
# 3. node backend/scripts/encrypt-db.js --db /path/to/jama.db --key YOUR_KEY
|
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
||||||
# 4. Add DB_KEY=YOUR_KEY here, then: ./build.sh && docker compose up -d
|
|
||||||
DB_KEY=
|
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||||
|
PORT=3000
|
||||||
|
TZ=UTC
|
||||||
|
|||||||
86
Caddyfile.example
Normal file
86
Caddyfile.example
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Caddyfile.example — JAMA-HOST reverse proxy
|
||||||
|
#
|
||||||
|
# Caddy handles SSL automatically via Let's Encrypt.
|
||||||
|
# Wildcard certs require a DNS challenge provider.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Install the Caddy DNS plugin for your provider:
|
||||||
|
# https://caddyserver.com/docs/automatic-https#dns-challenge
|
||||||
|
# Common providers: cloudflare, route53, digitalocean
|
||||||
|
#
|
||||||
|
# 2. Set your DNS API token as an environment variable:
|
||||||
|
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
|
||||||
|
#
|
||||||
|
# 3. Add a wildcard DNS record in your DNS provider:
|
||||||
|
# *.jamachat.com → your server IP
|
||||||
|
# jamachat.com → your server IP
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
|
||||||
|
# Reload: caddy reload
|
||||||
|
|
||||||
|
# ── Wildcard subdomain ────────────────────────────────────────────────────────
|
||||||
|
# Handles team1.jamachat.com, teamB.jamachat.com, etc.
|
||||||
|
# Replace jamachat.com with your actual HOST_DOMAIN.
|
||||||
|
|
||||||
|
*.jamachat.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward all requests to the jama app container
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logs (optional)
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/jama-access.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Base domain (host admin panel) ───────────────────────────────────────────
|
||||||
|
jamachat.com {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Custom tenant domains ─────────────────────────────────────────────────────
|
||||||
|
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
|
||||||
|
#
|
||||||
|
# 1. They add a DNS CNAME: chat.theircompany.com → jamachat.com
|
||||||
|
#
|
||||||
|
# 2. You add a block here and reload Caddy.
|
||||||
|
# Caddy will automatically obtain and renew the SSL cert.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
#
|
||||||
|
# chat.theircompany.com {
|
||||||
|
# reverse_proxy localhost:3000
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# Alternatively, use Caddy's on-demand TLS to handle custom domains
|
||||||
|
# automatically without editing this file:
|
||||||
|
#
|
||||||
|
# (on_demand_tls) {
|
||||||
|
# on_demand {
|
||||||
|
# ask http://localhost:3000/api/host/verify-domain
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# *.jamachat.com, jamachat.com {
|
||||||
|
# tls { on_demand }
|
||||||
|
# reverse_proxy localhost:3000
|
||||||
|
# }
|
||||||
21
Dockerfile
21
Dockerfile
@@ -2,47 +2,32 @@ ARG VERSION=dev
|
|||||||
ARG BUILD_DATE=unknown
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install frontend dependencies and build
|
|
||||||
COPY frontend/package*.json ./frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
RUN cd frontend && npm install
|
RUN cd frontend && npm install
|
||||||
|
|
||||||
COPY frontend/ ./frontend/
|
COPY frontend/ ./frontend/
|
||||||
RUN cd frontend && npm run build
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
# Backend
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG BUILD_DATE=unknown
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="jama" \
|
LABEL org.opencontainers.image.title="jama" \
|
||||||
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
org.opencontainers.image.created="${BUILD_DATE}"
|
||||||
org.opencontainers.image.source="https://github.com/yourorg/jama"
|
|
||||||
|
|
||||||
ENV JAMA_VERSION=${VERSION}
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Remove build tools after compile to keep image lean
|
|
||||||
RUN apk del python3 make g++
|
|
||||||
|
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
COPY --from=builder /app/frontend/dist ./public
|
COPY --from=builder /app/frontend/dist ./public
|
||||||
|
|
||||||
# Create data and uploads directories
|
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||||
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.9.87",
|
"version": "0.10.1",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"socket.io": "^4.6.1",
|
"socket.io": "^4.6.1",
|
||||||
"web-push": "^3.6.7",
|
"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": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
@@ -1,54 +1,53 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db');
|
|
||||||
|
const {
|
||||||
|
initDb, tenantMiddleware,
|
||||||
|
query, queryOne, queryResult, exec,
|
||||||
|
APP_TYPE, refreshTenantCache,
|
||||||
|
} = require('./models/db');
|
||||||
|
|
||||||
const { router: pushRouter, sendPushToUser } = require('./routes/push');
|
const { router: pushRouter, sendPushToUser } = require('./routes/push');
|
||||||
const { getLinkPreview } = require('./utils/linkPreview');
|
const { getLinkPreview } = require('./utils/linkPreview');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = new Server(server, {
|
const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } });
|
||||||
cors: { origin: '*', methods: ['GET', 'POST'] }
|
|
||||||
});
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// Init DB
|
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||||
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
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
app.use(tenantMiddleware);
|
||||||
app.use('/uploads', express.static('/app/uploads'));
|
app.use('/uploads', express.static('/app/uploads'));
|
||||||
|
|
||||||
// API Routes
|
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||||
app.use('/api/auth', require('./routes/auth')(io));
|
app.use('/api/auth', require('./routes/auth')(io));
|
||||||
app.use('/api/users', require('./routes/users'));
|
app.use('/api/users', require('./routes/users'));
|
||||||
app.use('/api/groups', require('./routes/groups')(io));
|
app.use('/api/groups', require('./routes/groups')(io));
|
||||||
app.use('/api/messages', require('./routes/messages')(io));
|
app.use('/api/messages', require('./routes/messages')(io));
|
||||||
app.use('/api/usergroups', require('./routes/usergroups')(io));
|
app.use('/api/usergroups', require('./routes/usergroups')(io));
|
||||||
app.use('/api/schedule', require('./routes/schedule'));
|
app.use('/api/schedule', require('./routes/schedule'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
app.use('/api/about', require('./routes/about'));
|
app.use('/api/about', require('./routes/about'));
|
||||||
app.use('/api/help', require('./routes/help'));
|
app.use('/api/help', require('./routes/help'));
|
||||||
app.use('/api/push', pushRouter);
|
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) => {
|
app.get('/api/link-preview', async (req, res) => {
|
||||||
const { url } = req.query;
|
const { url } = req.query;
|
||||||
if (!url) return res.status(400).json({ error: 'URL required' });
|
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 });
|
res.json({ preview });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check
|
// ── Health check ──────────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
// Dynamic manifest — must be before express.static so it takes precedence
|
// ── Dynamic PWA manifest ──────────────────────────────────────────────────────
|
||||||
app.get('/manifest.json', (req, res) => {
|
app.get('/manifest.json', async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all();
|
const rows = await query(req.schema,
|
||||||
const s = {};
|
"SELECT key, value FROM settings WHERE key IN ('app_name','logo_url','pwa_icon_192','pwa_icon_512')"
|
||||||
for (const r of rows) s[r.key] = r.value;
|
);
|
||||||
|
const s = {};
|
||||||
|
for (const r of rows) s[r.key] = r.value;
|
||||||
|
|
||||||
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
||||||
const pwa192 = s.pwa_icon_192 || '';
|
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
||||||
const pwa512 = s.pwa_icon_512 || '';
|
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
|
||||||
|
|
||||||
// Use uploaded+resized icons if they exist, else fall back to bundled PNGs.
|
const icons = [
|
||||||
// Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts.
|
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
const icon192 = pwa192 || '/icons/icon-192.png';
|
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||||
const icon512 = pwa512 || '/icons/icon-512.png';
|
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
];
|
||||||
|
|
||||||
const icons = [
|
res.setHeader('Content-Type', 'application/manifest+json');
|
||||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
res.json({
|
||||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
name: appName,
|
||||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
||||||
];
|
description: `${appName} - Team messaging`,
|
||||||
|
start_url: '/', scope: '/', display: 'standalone',
|
||||||
const manifest = {
|
orientation: 'portrait-primary',
|
||||||
name: appName,
|
background_color: '#ffffff', theme_color: '#1a73e8',
|
||||||
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
icons,
|
||||||
description: `${appName} - Team messaging`,
|
});
|
||||||
start_url: '/',
|
} catch (err) {
|
||||||
scope: '/',
|
res.status(500).json({ error: err.message });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve frontend
|
// ── Frontend ──────────────────────────────────────────────────────────────────
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Socket.io authentication
|
// ── Socket.io authentication ──────────────────────────────────────────────────
|
||||||
io.use((socket, next) => {
|
// 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;
|
const token = socket.handshake.auth.token;
|
||||||
if (!token) return next(new Error('Unauthorized'));
|
if (!token) return next(new Error('Unauthorized'));
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
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'));
|
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'));
|
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.device = session.device;
|
||||||
|
socket.schema = schema;
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(new Error('Invalid token'));
|
next(new Error('Invalid token'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track online users: userId -> Set of socketIds
|
// ── Online user tracking ──────────────────────────────────────────────────────
|
||||||
const onlineUsers = new Map();
|
const onlineUsers = new Map(); // userId → Set<socketId>
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', async (socket) => {
|
||||||
const userId = socket.user.id;
|
const userId = socket.user.id;
|
||||||
|
const schema = socket.schema;
|
||||||
|
|
||||||
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
||||||
onlineUsers.get(userId).add(socket.id);
|
onlineUsers.get(userId).add(socket.id);
|
||||||
|
|
||||||
// Record last_online timestamp
|
// Update last_online
|
||||||
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(() => {});
|
||||||
|
|
||||||
// Broadcast online status
|
|
||||||
io.emit('user:online', { userId });
|
io.emit('user:online', { userId });
|
||||||
|
|
||||||
// Join personal room for direct notifications
|
|
||||||
socket.join(`user:${userId}`);
|
socket.join(`user:${userId}`);
|
||||||
|
|
||||||
// Join rooms for all user's groups
|
// Join socket rooms for all groups this user belongs to
|
||||||
const db = getDb();
|
try {
|
||||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||||
for (const g of publicGroups) socket.join(`group:${g.id}`);
|
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);
|
const privateGroups = await query(schema,
|
||||||
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
|
'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.on('group:leave-room', ({ groupId }) => socket.leave(`group:${groupId}`));
|
||||||
socket.join(`group:${groupId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When a user leaves a group, remove them from the socket room
|
// ── New message ─────────────────────────────────────────────────────────────
|
||||||
socket.on('group:leave-room', ({ groupId }) => {
|
|
||||||
socket.leave(`group:${groupId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle new message
|
|
||||||
socket.on('message:send', async (data) => {
|
socket.on('message:send', async (data) => {
|
||||||
const { groupId, content, replyToId, imageUrl, linkPreview } = 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.type === 'private') {
|
||||||
if (!group) return;
|
const member = await queryOne(schema,
|
||||||
if (group.is_readonly && socket.user.role !== 'admin') return;
|
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
|
||||||
|
[groupId, userId]
|
||||||
// Check access
|
);
|
||||||
if (group.type === 'private') {
|
if (!member) return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Process @mentions — format is @[display name], look up user by display_name or name
|
const mr = await queryResult(schema, `
|
||||||
if (content) {
|
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
|
||||||
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
|
||||||
for (const mentionName of mentionNames) {
|
`, [
|
||||||
const mentionedUser = db.prepare(
|
groupId, userId,
|
||||||
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
|
content || null,
|
||||||
).get(mentionName, mentionName);
|
imageUrl || null,
|
||||||
const matchId = mentionedUser?.id?.toString();
|
imageUrl ? 'image' : 'text',
|
||||||
if (matchId && parseInt(matchId) !== userId) {
|
replyToId || null,
|
||||||
const notifResult = db.prepare(`
|
linkPreview ? JSON.stringify(linkPreview) : null,
|
||||||
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
|
]);
|
||||||
VALUES (?, 'mention', ?, ?, ?)
|
const msgId = mr.rows[0].id;
|
||||||
`).run(parseInt(matchId), result.lastInsertRowid, groupId, userId);
|
|
||||||
|
|
||||||
// Notify mentioned user — socket if online, push if not
|
const message = await queryOne(schema, `
|
||||||
const mentionedUserId = parseInt(matchId);
|
SELECT m.*,
|
||||||
const notif = {
|
u.name AS user_name, u.display_name AS user_display_name,
|
||||||
id: notifResult.lastInsertRowid,
|
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
|
||||||
type: 'mention',
|
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me,
|
||||||
groupId,
|
rm.content AS reply_content, rm.image_url AS reply_image_url,
|
||||||
messageId: result.lastInsertRowid,
|
rm.is_deleted AS reply_is_deleted,
|
||||||
fromUser: socket.user,
|
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
|
||||||
};
|
FROM messages m
|
||||||
if (onlineUsers.has(mentionedUserId)) {
|
JOIN users u ON m.user_id = u.id
|
||||||
for (const sid of onlineUsers.get(mentionedUserId)) {
|
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||||
io.to(sid).emit('notification:new', notif);
|
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`,
|
title: `${senderName} mentioned you`,
|
||||||
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
||||||
url: '/',
|
url: '/', badge: 1,
|
||||||
badge: 1,
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Socket] message:send error:', e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces
|
// ── Reaction toggle ─────────────────────────────────────────────────────────
|
||||||
socket.on('reaction:toggle', (data) => {
|
socket.on('reaction:toggle', async ({ messageId, emoji }) => {
|
||||||
const { messageId, emoji } = data;
|
try {
|
||||||
const db = getDb();
|
const message = await queryOne(schema,
|
||||||
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);
|
'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',
|
||||||
if (!message) return;
|
[messageId]
|
||||||
|
);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
// Find any existing reaction by this user on this message
|
const existing = await queryOne(schema,
|
||||||
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId);
|
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2',
|
||||||
|
[messageId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.emoji === emoji) {
|
if (existing.emoji === emoji) {
|
||||||
// Same emoji — toggle off (remove)
|
await exec(schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
||||||
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
|
} else {
|
||||||
|
await exec(schema, 'UPDATE reactions SET emoji=$1 WHERE id=$2', [emoji, existing.id]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Different emoji — replace
|
await exec(schema,
|
||||||
db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id);
|
'INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)',
|
||||||
|
[messageId, userId, emoji]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No existing reaction — insert
|
const reactions = await query(schema, `
|
||||||
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
|
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
|
// ── Message delete ──────────────────────────────────────────────────────────
|
||||||
socket.on('message:delete', (data) => {
|
socket.on('message:delete', async ({ messageId }) => {
|
||||||
const { messageId } = data;
|
try {
|
||||||
const db = getDb();
|
const message = await queryOne(schema, `
|
||||||
const message = db.prepare(`
|
SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id, g.is_direct
|
||||||
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
|
||||||
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
|
`, [messageId]);
|
||||||
`).get(messageId);
|
if (!message) return;
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
const isAdmin = socket.user.role === 'admin';
|
const isAdmin = socket.user.role === 'admin';
|
||||||
const isOwner = message.group_owner_id === userId;
|
const isOwner = message.group_owner_id === userId;
|
||||||
const isAuthor = message.user_id === userId;
|
const isAuthor = message.user_id === userId;
|
||||||
|
let canDelete = isAuthor || isOwner;
|
||||||
|
|
||||||
// Rules:
|
if (!canDelete && isAdmin) {
|
||||||
// 1. Author can always delete their own message
|
if (message.group_type === 'public') {
|
||||||
// 2. Admin can delete in any public group or any group they're a member of
|
canDelete = true;
|
||||||
// 3. Group owner can delete any message in their group
|
} else {
|
||||||
// 4. In direct messages: author + owner rules apply (no blanket block)
|
const membership = await queryOne(schema,
|
||||||
let canDelete = isAuthor || isOwner;
|
'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2',
|
||||||
if (!canDelete && isAdmin) {
|
[message.group_id, userId]
|
||||||
if (message.group_type === 'public') {
|
);
|
||||||
canDelete = true;
|
if (membership) 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) 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.on('typing:start', ({ groupId }) => {
|
||||||
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
|
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 });
|
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get online users
|
|
||||||
socket.on('users:online', () => {
|
socket.on('users:online', () => {
|
||||||
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
|
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle disconnect
|
// ── Disconnect ──────────────────────────────────────────────────────────────
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
if (onlineUsers.has(userId)) {
|
if (onlineUsers.has(userId)) {
|
||||||
onlineUsers.get(userId).delete(socket.id);
|
onlineUsers.get(userId).delete(socket.id);
|
||||||
if (onlineUsers.get(userId).size === 0) {
|
if (onlineUsers.get(userId).size === 0) {
|
||||||
onlineUsers.delete(userId);
|
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 });
|
io.emit('user:offline', { userId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
console.log(`jama server running on port ${PORT}`);
|
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 };
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, exec } = require('../models/db');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
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) {
|
function getDeviceClass(ua) {
|
||||||
if (!ua) return 'desktop';
|
if (!ua) return 'desktop';
|
||||||
const s = ua.toLowerCase();
|
const s = ua.toLowerCase();
|
||||||
@@ -13,24 +11,21 @@ function getDeviceClass(ua) {
|
|||||||
return 'desktop';
|
return 'desktop';
|
||||||
}
|
}
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
async function authMiddleware(req, res, next) {
|
||||||
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
|
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
|
||||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
const db = getDb();
|
const user = await queryOne(req.schema,
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
|
"SELECT * FROM users WHERE id = $1 AND status = 'active'", [decoded.id]
|
||||||
|
);
|
||||||
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
|
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
|
||||||
|
const session = await queryOne(req.schema,
|
||||||
// Per-device enforcement: token must match an active session row
|
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2', [decoded.id, token]
|
||||||
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
|
);
|
||||||
if (!session) {
|
if (!session) return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
||||||
return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
req.user = user;
|
||||||
}
|
req.token = token;
|
||||||
|
|
||||||
req.user = user;
|
|
||||||
req.token = token;
|
|
||||||
req.device = session.device;
|
req.device = session.device;
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -43,52 +38,57 @@ function adminMiddleware(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allows admins OR members of groups designated as Tool Managers
|
async function teamManagerMiddleware(req, res, next) {
|
||||||
function teamManagerMiddleware(req, res, next) {
|
|
||||||
if (req.user?.role === 'admin') return next();
|
if (req.user?.role === 'admin') return next();
|
||||||
const db = getDb();
|
try {
|
||||||
// Prefer unified key, fall back to legacy keys for older installs
|
const tmSetting = await queryOne(req.schema,
|
||||||
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
|
"SELECT value FROM settings WHERE key = 'team_tool_managers'"
|
||||||
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
|
);
|
||||||
const allowedGroupIds = [
|
const gmSetting = await queryOne(req.schema,
|
||||||
...new Set([
|
"SELECT value FROM settings WHERE key = 'team_group_managers'"
|
||||||
...JSON.parse(tmSetting?.value || '[]'),
|
);
|
||||||
...JSON.parse(gmSetting?.value || '[]'),
|
const allowedGroupIds = [
|
||||||
])
|
...new Set([
|
||||||
];
|
...JSON.parse(tmSetting?.value || '[]'),
|
||||||
if (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
|
...JSON.parse(gmSetting?.value || '[]'),
|
||||||
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 (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
|
||||||
if (!member) return res.status(403).json({ error: 'Access denied' });
|
const placeholders = allowedGroupIds.map((_, i) => `$${i + 2}`).join(',');
|
||||||
next();
|
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) {
|
function generateToken(userId) {
|
||||||
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
|
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert the active session for this user+device class.
|
async function setActiveSession(schema, userId, token, userAgent) {
|
||||||
// Displaces any prior session on the same device class; the other device class is unaffected.
|
|
||||||
function setActiveSession(userId, token, userAgent) {
|
|
||||||
const db = getDb();
|
|
||||||
const device = getDeviceClass(userAgent);
|
const device = getDeviceClass(userAgent);
|
||||||
db.prepare(`
|
await exec(schema, `
|
||||||
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
|
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
|
||||||
VALUES (?, ?, ?, ?, datetime('now'))
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
ON CONFLICT(user_id, device) DO UPDATE SET token = ?, ua = ?, created_at = datetime('now')
|
ON CONFLICT (user_id, device) DO UPDATE SET token = $3, ua = $4, created_at = NOW()
|
||||||
`).run(userId, device, token, userAgent || null, token, userAgent || null);
|
`, [userId, device, token, userAgent || null]);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear one device slot on logout, or all slots (no device arg) for suspend/delete
|
async function clearActiveSession(schema, userId, device) {
|
||||||
function clearActiveSession(userId, device) {
|
|
||||||
const db = getDb();
|
|
||||||
if (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 {
|
} 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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 path = require('path');
|
||||||
const fs = require('fs');
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
|
const APP_TYPE = process.env.APP_TYPE || 'selfhost';
|
||||||
const DB_KEY = process.env.DB_KEY || '';
|
|
||||||
|
|
||||||
let db;
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getDb() {
|
const pool = new Pool({
|
||||||
if (!db) {
|
host: process.env.DB_HOST || 'db',
|
||||||
// Ensure the data directory exists before opening the DB
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
const dir = path.dirname(DB_PATH);
|
database: process.env.DB_NAME || 'jama',
|
||||||
if (!fs.existsSync(dir)) {
|
user: process.env.DB_USER || 'jama',
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
password: process.env.DB_PASSWORD || '',
|
||||||
console.log(`[DB] Created data directory: ${dir}`);
|
max: 20,
|
||||||
}
|
idleTimeoutMillis: 30000,
|
||||||
db = new Database(DB_PATH);
|
connectionTimeoutMillis: 5000,
|
||||||
if (DB_KEY) {
|
});
|
||||||
// Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser
|
|
||||||
// Must be applied before any other DB access
|
pool.on('error', (err) => {
|
||||||
const safeKey = DB_KEY.replace(/'/g, "''");
|
console.error('[DB] Unexpected pool error:', err.message);
|
||||||
db.pragma(`cipher='sqlcipher'`);
|
});
|
||||||
db.pragma(`legacy=4`);
|
|
||||||
db.pragma(`key='${safeKey}'`);
|
// ── Schema resolution ─────────────────────────────────────────────────────────
|
||||||
console.log('[DB] Encryption key applied (SQLCipher4)');
|
|
||||||
} else {
|
const tenantDomainCache = new Map();
|
||||||
console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted');
|
|
||||||
}
|
function resolveSchema(req) {
|
||||||
const journalMode = db.pragma('journal_mode = WAL', { simple: true });
|
if (APP_TYPE === 'selfhost') return 'public';
|
||||||
if (journalMode !== 'wal') {
|
|
||||||
console.warn(`[DB] WARNING: journal_mode is '${journalMode}', expected 'wal' — performance may be degraded`);
|
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||||
}
|
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
|
||||||
db.pragma('synchronous = NORMAL'); // safe with WAL, faster than FULL
|
|
||||||
db.pragma('cache_size = -8000'); // 8MB page cache
|
// Internal requests (Docker health checks, localhost) → public schema
|
||||||
db.pragma('foreign_keys = ON');
|
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||||
console.log(`[DB] Opened database at ${DB_PATH} (journal=${journalMode})`);
|
|
||||||
|
// 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() {
|
function refreshTenantCache(tenants) {
|
||||||
const db = getDb();
|
tenantDomainCache.clear();
|
||||||
|
for (const t of tenants) {
|
||||||
|
if (t.custom_domain) {
|
||||||
|
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.exec(`
|
// ── Schema name safety guard ──────────────────────────────────────────────────
|
||||||
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'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS groups (
|
function assertSafeSchema(schema) {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
|
||||||
name TEXT NOT NULL,
|
throw new Error(`Unsafe schema name rejected: ${schema}`);
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS group_members (
|
// ── Core query helpers ────────────────────────────────────────────────────────
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
async function query(schema, sql, params = []) {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
assertSafeSchema(schema);
|
||||||
group_id INTEGER NOT NULL,
|
const client = await pool.connect();
|
||||||
user_id INTEGER NOT NULL,
|
try {
|
||||||
content TEXT,
|
await client.query(`SET search_path TO "${schema}", public`);
|
||||||
type TEXT NOT NULL DEFAULT 'text',
|
const result = await client.query(sql, params);
|
||||||
image_url TEXT,
|
return result.rows;
|
||||||
reply_to_id INTEGER,
|
} finally {
|
||||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
client.release();
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reactions (
|
async function queryOne(schema, sql, params = []) {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
const rows = await query(schema, sql, params);
|
||||||
message_id INTEGER NOT NULL,
|
return rows[0] || 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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
async function queryResult(schema, sql, params = []) {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
assertSafeSchema(schema);
|
||||||
user_id INTEGER NOT NULL,
|
const client = await pool.connect();
|
||||||
type TEXT NOT NULL,
|
try {
|
||||||
message_id INTEGER,
|
await client.query(`SET search_path TO "${schema}", public`);
|
||||||
group_id INTEGER,
|
return await client.query(sql, params);
|
||||||
from_user_id INTEGER,
|
} finally {
|
||||||
is_read INTEGER NOT NULL DEFAULT 0,
|
client.release();
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
}
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
}
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
async function exec(schema, sql, params = []) {
|
||||||
id TEXT PRIMARY KEY,
|
await query(schema, sql, params);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
async function withTransaction(schema, callback) {
|
||||||
key TEXT PRIMARY KEY,
|
assertSafeSchema(schema);
|
||||||
value TEXT NOT NULL,
|
const client = await pool.connect();
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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 (
|
// ── Migration runner ──────────────────────────────────────────────────────────
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
async function ensureSchema(schema) {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
assertSafeSchema(schema);
|
||||||
user_id INTEGER NOT NULL,
|
// Use a direct client outside of search_path for schema creation
|
||||||
endpoint TEXT NOT NULL,
|
const client = await pool.connect();
|
||||||
p256dh TEXT NOT NULL,
|
try {
|
||||||
auth TEXT NOT NULL,
|
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||||
device TEXT NOT NULL DEFAULT 'desktop',
|
} finally {
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
client.release();
|
||||||
UNIQUE(user_id, device),
|
}
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
}
|
||||||
);
|
|
||||||
|
|
||||||
-- User groups (admin-managed, separate from chat groups)
|
async function runMigrations(schema) {
|
||||||
CREATE TABLE IF NOT EXISTS user_groups (
|
await ensureSchema(schema);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Members of user groups
|
await exec(schema, `
|
||||||
CREATE TABLE IF NOT EXISTS user_group_members (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
user_group_id INTEGER NOT NULL,
|
version INTEGER PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
name TEXT NOT NULL,
|
||||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT 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
|
|
||||||
);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Initialize default settings
|
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
|
||||||
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
const appliedSet = new Set(applied.map(r => r.version));
|
||||||
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', '');
|
|
||||||
|
|
||||||
// Migration: add hide_admin_tag if upgrading from older version
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
try {
|
const files = fs.readdirSync(migrationsDir)
|
||||||
db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0");
|
.filter(f => f.endsWith('.sql'))
|
||||||
console.log('[DB] Migration: added hide_admin_tag column');
|
.sort();
|
||||||
} catch (e) { /* column already exists */ }
|
|
||||||
|
|
||||||
// Migration: add allow_dm if upgrading from older version
|
for (const file of files) {
|
||||||
try {
|
const m = file.match(/^(\d+)_/);
|
||||||
db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1");
|
if (!m) continue;
|
||||||
console.log('[DB] Migration: added allow_dm column');
|
const version = parseInt(m[1]);
|
||||||
} catch (e) { /* column already exists */ }
|
if (appliedSet.has(version)) continue;
|
||||||
|
|
||||||
// Migration: replace single-session active_sessions with per-device version
|
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||||
try {
|
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
|
||||||
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); }
|
|
||||||
|
|
||||||
// Migration: add is_direct for user-to-user direct messages
|
await withTransaction(schema, async (client) => {
|
||||||
try {
|
await client.query(sql);
|
||||||
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
|
await client.query(
|
||||||
console.log('[DB] Migration: added is_direct column');
|
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
|
||||||
} catch (e) { /* column already exists */ }
|
[version, file]
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
});
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
event_type_id INTEGER,
|
|
||||||
start_at TEXT NOT NULL,
|
|
||||||
end_at TEXT NOT NULL,
|
|
||||||
all_day INTEGER NOT NULL DEFAULT 0,
|
|
||||||
location TEXT,
|
|
||||||
description TEXT,
|
|
||||||
is_public INTEGER NOT NULL DEFAULT 1,
|
|
||||||
track_availability INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_by INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS event_user_groups (
|
|
||||||
event_id INTEGER NOT NULL,
|
|
||||||
user_group_id INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (event_id, user_group_id),
|
|
||||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS event_availability (
|
|
||||||
event_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (event_id, user_id),
|
|
||||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
// Migration: add columns if missing (must run before inserts)
|
|
||||||
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
|
|
||||||
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
|
|
||||||
try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } catch(e) {}
|
|
||||||
// Delete the legacy "Default" type — "Event" is the canonical default
|
|
||||||
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
|
|
||||||
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
|
|
||||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").run();
|
|
||||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
|
|
||||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
|
|
||||||
// Remove duplicates — keep the one with is_default=1
|
|
||||||
const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all();
|
|
||||||
if (evtTypes.length > 1) {
|
|
||||||
for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id);
|
|
||||||
}
|
|
||||||
// Ensure built-in types are correct
|
|
||||||
db.prepare("UPDATE event_types SET is_protected = 1, is_default = 1, default_duration_hrs = 1.0 WHERE name = 'Event'").run();
|
|
||||||
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run();
|
|
||||||
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").run();
|
|
||||||
console.log('[DB] Schedule Manager tables ready');
|
|
||||||
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
|
|
||||||
|
|
||||||
console.log('[DB] Schema initialized');
|
console.log(`[DB:${schema}] Migration ${version} done`);
|
||||||
return db;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedAdmin() {
|
// ── Seeding ───────────────────────────────────────────────────────────────────
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Strip any surrounding quotes from env vars (common docker-compose mistake)
|
async function seedSettings(schema) {
|
||||||
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim();
|
const defaults = [
|
||||||
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
|
['app_name', process.env.APP_NAME || 'jama'],
|
||||||
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
|
['logo_url', ''],
|
||||||
|
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
||||||
|
['icon_newchat', ''],
|
||||||
|
['icon_groupinfo', ''],
|
||||||
|
['pwa_icon_192', ''],
|
||||||
|
['pwa_icon_512', ''],
|
||||||
|
['color_title', ''],
|
||||||
|
['color_title_dark', ''],
|
||||||
|
['color_avatar_public', ''],
|
||||||
|
['color_avatar_dm', ''],
|
||||||
|
['registration_code', ''],
|
||||||
|
['feature_branding', 'false'],
|
||||||
|
['feature_group_manager', 'false'],
|
||||||
|
['feature_schedule_manager', 'false'],
|
||||||
|
['app_type', 'JAMA-Chat'],
|
||||||
|
['team_group_managers', ''],
|
||||||
|
['team_schedule_managers', ''],
|
||||||
|
['team_tool_managers', ''],
|
||||||
|
];
|
||||||
|
for (const [key, value] of defaults) {
|
||||||
|
await exec(schema,
|
||||||
|
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
|
||||||
|
[key, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedEventTypes(schema) {
|
||||||
|
await exec(schema, `
|
||||||
|
INSERT INTO event_types (name, colour, is_default, is_protected, default_duration_hrs)
|
||||||
|
VALUES ('Event', '#6366f1', TRUE, TRUE, 1.0)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET is_default=TRUE, is_protected=TRUE, default_duration_hrs=1.0
|
||||||
|
`);
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0) ON CONFLICT (name) DO NOTHING"
|
||||||
|
);
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0) ON CONFLICT (name) DO NOTHING"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAdmin(schema) {
|
||||||
|
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||||
|
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
|
||||||
|
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||||
|
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
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) {
|
if (!existing) {
|
||||||
try {
|
const hash = bcrypt.hashSync(adminPass, 10);
|
||||||
const hash = bcrypt.hashSync(adminPass, 10);
|
const ur = await queryResult(schema, `
|
||||||
const result = db.prepare(`
|
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
|
||||||
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
|
VALUES ($1, $2, $3, 'admin', 'active', TRUE, TRUE) RETURNING id
|
||||||
VALUES (?, ?, ?, 'admin', 'active', 1, 1)
|
`, [adminName, adminEmail, hash]);
|
||||||
`).run(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 sr = await queryResult(schema,
|
||||||
const groupResult = db.prepare(`
|
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', $1, FALSE) RETURNING id",
|
||||||
INSERT INTO groups (name, type, is_default, owner_id)
|
[adminId]
|
||||||
VALUES (?, 'public', 1, ?)
|
);
|
||||||
`).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid);
|
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
|
console.log(`[DB:${schema}] Default admin + groups created`);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DB] Default admin already exists (id=${existing.id})`);
|
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
|
||||||
|
|
||||||
// Handle ADMPW_RESET
|
|
||||||
if (pwReset) {
|
if (pwReset) {
|
||||||
const hash = bcrypt.hashSync(adminPass, 10);
|
const hash = bcrypt.hashSync(adminPass, 10);
|
||||||
db.prepare(`
|
await exec(schema,
|
||||||
UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now')
|
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||||
WHERE is_default_admin = 1
|
[hash]
|
||||||
`).run(hash);
|
);
|
||||||
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
await exec(schema, "UPDATE settings SET value='true', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||||
console.log('[DB] Admin password reset via ADMPW_RESET=true');
|
console.log(`[DB:${schema}] Admin password reset`);
|
||||||
} else {
|
} 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) {
|
// ── Main init (called on server startup) ─────────────────────────────────────
|
||||||
const db = getDb();
|
|
||||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
async function initDb() {
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
// Wait for Postgres to be ready (up to 30s)
|
||||||
for (const g of publicGroups) {
|
for (let i = 0; i < 30; i++) {
|
||||||
insert.run(g.id, userId);
|
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() {
|
async function getOrCreateSupportGroup(schema) {
|
||||||
const db = getDb();
|
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 admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||||
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();
|
|
||||||
if (!admin) return null;
|
if (!admin) return null;
|
||||||
|
|
||||||
const result = db.prepare(`
|
const r = await queryResult(schema,
|
||||||
INSERT INTO groups (name, type, owner_id, is_default)
|
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support','private',$1,FALSE) RETURNING id",
|
||||||
VALUES ('Support', 'private', ?, 0)
|
[admin.id]
|
||||||
`).run(admin.id);
|
);
|
||||||
|
const groupId = r.rows[0].id;
|
||||||
const groupId = result.lastInsertRowid;
|
const admins = await query(schema, "SELECT id FROM users WHERE role='admin' AND status='active'");
|
||||||
|
for (const a of admins) {
|
||||||
// Add all current admins to the Support group
|
await exec(schema,
|
||||||
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
|
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
[groupId, a.id]
|
||||||
for (const a of admins) insert.run(groupId, a.id);
|
);
|
||||||
|
}
|
||||||
console.log('[DB] Support group created');
|
|
||||||
return groupId;
|
return groupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateSupportGroup() {
|
// ── Tenant middleware ─────────────────────────────────────────────────────────
|
||||||
const db = getDb();
|
|
||||||
const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
|
function tenantMiddleware(req, res, next) {
|
||||||
if (group) return group.id;
|
try {
|
||||||
return seedSupportGroup();
|
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,
|
||||||
|
};
|
||||||
|
|||||||
213
backend/src/models/migrations/001_initial_schema.sql
Normal file
213
backend/src/models/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
-- Migration 001: Initial schema
|
||||||
|
-- Converts all SQLite tables to Postgres-native types.
|
||||||
|
-- TIMESTAMPTZ replaces TEXT for dates.
|
||||||
|
-- SERIAL replaces AUTOINCREMENT.
|
||||||
|
-- Constraints use Postgres syntax throughout.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
is_default_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
avatar TEXT,
|
||||||
|
about_me TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
hide_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
allow_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_online TIMESTAMPTZ,
|
||||||
|
help_dismissed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'public',
|
||||||
|
owner_id INTEGER REFERENCES users(id),
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
direct_peer1_id INTEGER,
|
||||||
|
direct_peer2_id INTEGER,
|
||||||
|
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_multi_group BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(group_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
content TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
image_url TEXT,
|
||||||
|
reply_to_id INTEGER REFERENCES messages(id),
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
link_preview TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(message_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
message_id INTEGER,
|
||||||
|
group_id INTEGER,
|
||||||
|
from_user_id INTEGER,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device TEXT NOT NULL DEFAULT 'desktop',
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
ua TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, device)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL DEFAULT 'desktop',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, device)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_group_names (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pinned_conversations (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_group_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS multi_group_dms (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
|
||||||
|
multi_group_dm_id INTEGER NOT NULL REFERENCES multi_group_dms(id) ON DELETE CASCADE,
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (multi_group_dm_id, user_group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Schedule Manager ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
colour TEXT NOT NULL DEFAULT '#6366f1',
|
||||||
|
default_user_group_id INTEGER REFERENCES user_groups(id) ON DELETE SET NULL,
|
||||||
|
default_duration_hrs NUMERIC,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_protected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
event_type_id INTEGER REFERENCES event_types(id) ON DELETE SET NULL,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
location TEXT,
|
||||||
|
description TEXT,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
track_availability BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
recurrence_rule JSONB,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_user_groups (
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (event_id, user_group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_availability (
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (event_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Indexes for common query patterns ────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_group_id ON messages(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
|
||||||
96
backend/src/models/migrations/002_triggers_and_indexes.sql
Normal file
96
backend/src/models/migrations/002_triggers_and_indexes.sql
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
-- Migration 002: updated_at auto-trigger + additional indexes
|
||||||
|
--
|
||||||
|
-- Adds a reusable Postgres trigger function that automatically sets
|
||||||
|
-- updated_at = NOW() on any UPDATE, eliminating the need to set it
|
||||||
|
-- manually in every route. Also adds a few missing indexes.
|
||||||
|
|
||||||
|
-- ── Auto-updated_at trigger function ─────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply to all tables that have an updated_at column
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_groups_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_groups_updated_at
|
||||||
|
BEFORE UPDATE ON groups
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_settings_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_settings_updated_at
|
||||||
|
BEFORE UPDATE ON settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_user_groups_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_user_groups_updated_at
|
||||||
|
BEFORE UPDATE ON user_groups
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_multi_group_dms_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_multi_group_dms_updated_at
|
||||||
|
BEFORE UPDATE ON multi_group_dms
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_events_updated_at
|
||||||
|
BEFORE UPDATE ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── Additional indexes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Notifications: most queries filter by user + read status
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||||
|
ON notifications(user_id, is_read)
|
||||||
|
WHERE is_read = FALSE;
|
||||||
|
|
||||||
|
-- Sessions: lookup by user is common on logout / session cleanup
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
||||||
|
ON sessions(user_id);
|
||||||
|
|
||||||
|
-- Active sessions: covered by PK (user_id, device) but explicit for clarity
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_active_sessions_token
|
||||||
|
ON active_sessions(token);
|
||||||
|
|
||||||
|
-- Push subscriptions: lookup by user is the hot path
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user
|
||||||
|
ON push_subscriptions(user_id);
|
||||||
|
|
||||||
|
-- User group members: reverse lookup (which groups is a user in?)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_group_members_user
|
||||||
|
ON user_group_members(user_id);
|
||||||
|
|
||||||
|
-- Event availability: reverse lookup (which events has a user responded to?)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_availability_user
|
||||||
|
ON event_availability(user_id);
|
||||||
|
|
||||||
|
-- Events: filter by created_by (schedule manager views)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_type
|
||||||
|
ON events(event_type_id);
|
||||||
31
backend/src/models/migrations/003_tenants.sql
Normal file
31
backend/src/models/migrations/003_tenants.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- Migration 003: Tenant registry (JAMA-HOST mode)
|
||||||
|
--
|
||||||
|
-- This table lives in the 'public' schema and is the source of truth for
|
||||||
|
-- all tenants in host mode. In selfhost mode this table exists but stays
|
||||||
|
-- empty — it has no effect on anything.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE, -- used as schema name: tenant_{slug}
|
||||||
|
name TEXT NOT NULL, -- display name
|
||||||
|
schema_name TEXT NOT NULL UNIQUE, -- actual Postgres schema: tenant_{slug}
|
||||||
|
custom_domain TEXT, -- optional: team1.example.com
|
||||||
|
plan TEXT NOT NULL DEFAULT 'chat', -- chat | brand | team
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active | suspended
|
||||||
|
admin_email TEXT, -- first admin email for this tenant
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
|
||||||
|
|
||||||
|
-- Auto-update updated_at
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_tenants_updated_at') THEN
|
||||||
|
CREATE TRIGGER trg_tenants_updated_at
|
||||||
|
BEFORE UPDATE ON tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
6
backend/src/models/migrations/004_host_plan.sql
Normal file
6
backend/src/models/migrations/004_host_plan.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migration 004: Host plan feature flags placeholder
|
||||||
|
--
|
||||||
|
-- Feature flag enforcement for APP_TYPE=host is handled in db.js initDb()
|
||||||
|
-- which runs on every startup and upserts the correct values.
|
||||||
|
-- This migration exists as a version marker — no SQL changes needed.
|
||||||
|
SELECT 1;
|
||||||
101
backend/src/models/migrations/MIGRATIONS.md
Normal file
101
backend/src/models/migrations/MIGRATIONS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# jama Migration Guide
|
||||||
|
|
||||||
|
## How migrations work
|
||||||
|
|
||||||
|
jama uses a simple file-based migration system. On every startup, `db.js` reads
|
||||||
|
all `.sql` files in this directory, sorted by version number, and applies any
|
||||||
|
that haven't been recorded in the `schema_migrations` table.
|
||||||
|
|
||||||
|
Migrations run inside a transaction — if anything fails, the whole migration
|
||||||
|
rolls back and the version is not recorded, so startup will retry it next time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new migration
|
||||||
|
|
||||||
|
1. Create a new file in this directory named `NNN_description.sql` where `NNN`
|
||||||
|
is the next sequential number (zero-padded to 3 digits):
|
||||||
|
|
||||||
|
```
|
||||||
|
001_initial_schema.sql ← already applied
|
||||||
|
002_add_user_preferences.sql
|
||||||
|
003_add_tenant_table.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Write standard Postgres SQL. Use `IF NOT EXISTS` / `IF EXISTS` guards where
|
||||||
|
possible so migrations are safe to replay:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add a new column
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
|
||||||
|
|
||||||
|
-- Add a new table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add an index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Deploy. On next startup jama will automatically detect and apply the new
|
||||||
|
migration, logging:
|
||||||
|
|
||||||
|
```
|
||||||
|
[DB:public] Applying migration 2: 002_add_user_preferences.sql
|
||||||
|
[DB:public] Migration 2 done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Never edit an applied migration.** Once `001_initial_schema.sql` has been
|
||||||
|
applied to any database, it must not change. Add a new numbered file instead.
|
||||||
|
|
||||||
|
- **Always use `IF NOT EXISTS` / `IF EXISTS`.** This makes migrations safe to
|
||||||
|
run against schemas that may be partially applied (e.g. after a failed deploy).
|
||||||
|
|
||||||
|
- **One logical change per file.** Easier to reason about and roll back mentally.
|
||||||
|
|
||||||
|
- **No data mutations in migrations unless unavoidable.** Seed data lives in
|
||||||
|
`db.js` (`seedSettings`, `seedEventTypes`, `seedAdmin`). Migrations are for
|
||||||
|
schema structure only.
|
||||||
|
|
||||||
|
- **JAMA-HOST:** When a new tenant is provisioned, `runMigrations(schema)` is
|
||||||
|
called on their fresh schema — they get all migrations from `001` onward
|
||||||
|
applied at creation time. Existing tenants get new migrations on the next
|
||||||
|
startup automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking migration status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to the running Postgres container
|
||||||
|
docker compose exec db psql -U jama -d jama
|
||||||
|
|
||||||
|
# See which migrations have been applied
|
||||||
|
SELECT * FROM schema_migrations ORDER BY version;
|
||||||
|
|
||||||
|
# In host mode, check a specific tenant schema
|
||||||
|
SET search_path TO tenant_teamname;
|
||||||
|
SELECT * FROM schema_migrations ORDER BY version;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emergency rollback
|
||||||
|
|
||||||
|
Migrations do not include automatic down/rollback scripts. If a migration causes
|
||||||
|
problems in production:
|
||||||
|
|
||||||
|
1. Stop the app container: `docker compose stop jama`
|
||||||
|
2. Connect to Postgres and manually reverse the change
|
||||||
|
3. Delete the migration record: `DELETE FROM schema_migrations WHERE version = NNN;`
|
||||||
|
4. Fix the migration file
|
||||||
|
5. Restart: `docker compose start jama`
|
||||||
@@ -5,7 +5,7 @@ const fs = require('fs');
|
|||||||
const ABOUT_FILE = '/app/data/about.json';
|
const ABOUT_FILE = '/app/data/about.json';
|
||||||
|
|
||||||
const DEFAULTS = {
|
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',
|
developer: 'Ricky Stretch',
|
||||||
license: 'AGPL 3.0',
|
license: 'AGPL 3.0',
|
||||||
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
|
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
|
||||||
|
|||||||
@@ -1,130 +1,100 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { getDb, getOrCreateSupportGroup } = require('../models/db');
|
const { query, queryOne, queryResult, exec, getOrCreateSupportGroup } = require('../models/db');
|
||||||
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
|
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
|
||||||
|
|
||||||
module.exports = function(io) {
|
module.exports = function(io) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { email, password, rememberMe } = req.body;
|
const { email, password, rememberMe } = req.body;
|
||||||
const db = getDb();
|
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.status === 'suspended') {
|
||||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
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') {
|
if (!bcrypt.compareSync(password, user.password))
|
||||||
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
return res.status(403).json({
|
|
||||||
error: 'suspended',
|
|
||||||
adminEmail: adminUser?.email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
|
|
||||||
|
|
||||||
const valid = bcrypt.compareSync(password, user.password);
|
const token = generateToken(user.id);
|
||||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
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 { password: _, ...userSafe } = user;
|
||||||
const ua = req.headers['user-agent'] || '';
|
res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe });
|
||||||
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
// 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
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
router.post('/change-password', authMiddleware, (req, res) => {
|
router.post('/change-password', authMiddleware, async (req, res) => {
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
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)) {
|
// Get current user
|
||||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
router.get('/me', authMiddleware, (req, res) => {
|
||||||
}
|
const { password, ...user } = req.user;
|
||||||
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
res.json({ user });
|
||||||
|
});
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(newPassword, 10);
|
// Logout
|
||||||
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
|
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
|
const admin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||||
router.get('/me', authMiddleware, (req, res) => {
|
if (!admin) return res.status(500).json({ error: 'No admin configured' });
|
||||||
const { password, ...user } = req.user;
|
|
||||||
res.json({ user });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logout — clear active session for this device class only
|
const content = `📬 **Support Request**\n**Name:** ${name.trim()}\n**Email:** ${email.trim()}\n\n${message.trim()}`;
|
||||||
router.post('/logout', authMiddleware, (req, res) => {
|
const mr = await queryResult(req.schema,
|
||||||
clearActiveSession(req.user.id, req.device);
|
"INSERT INTO messages (group_id, user_id, content, type) VALUES ($1,$2,$3,'text') RETURNING id",
|
||||||
res.json({ success: true });
|
[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
|
const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'");
|
||||||
router.post('/support', (req, res) => {
|
for (const a of admins) io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
|
||||||
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 db = getDb();
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
// 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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,450 +1,318 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
// Helper: emit group:new to all members of a group
|
function deleteImageFile(imageUrl) {
|
||||||
function emitGroupNew(io, groupId) {
|
if (!imageUrl) return;
|
||||||
const db = getDb();
|
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
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) return;
|
||||||
if (group.type === 'public') {
|
if (group.type === 'public') {
|
||||||
io.emit('group:new', { group });
|
io.emit('group:new', { group });
|
||||||
} else {
|
} else {
|
||||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||||
for (const m of members) {
|
for (const m of members) io.to(`user:${m.user_id}`).emit('group:new', { group });
|
||||||
io.to(`user:${m.user_id}`).emit('group:new', { group });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete an uploaded image file from disk
|
async function emitGroupUpdated(schema, io, groupId) {
|
||||||
function deleteImageFile(imageUrl) {
|
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||||
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);
|
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
let uids;
|
||||||
const uids = group.type === 'public'
|
if (group.type === 'public') {
|
||||||
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
|
uids = await query(schema, "SELECT id AS user_id FROM users WHERE status='active'");
|
||||||
: members;
|
} else {
|
||||||
for (const m of uids) {
|
uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||||
io.to(`user:${m.user_id}`).emit('group:updated', { group });
|
|
||||||
}
|
}
|
||||||
|
for (const m of uids) io.to(`user:${m.user_id}`).emit('group:updated', { group });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject io into routes
|
// GET all groups for current user
|
||||||
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
module.exports = (io) => {
|
try {
|
||||||
|
|
||||||
// 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];
|
|
||||||
const userId = req.user.id;
|
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 privateGroupsRaw = await query(req.schema, `
|
||||||
const existing = db.prepare(`
|
SELECT g.*, u.name AS owner_name,
|
||||||
SELECT g.id FROM groups g
|
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||||
JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ?
|
(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,
|
||||||
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
|
(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,
|
||||||
WHERE g.is_direct = 1
|
(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
|
||||||
LIMIT 1
|
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
||||||
`).get(userId, otherUserId);
|
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 privateGroups = await Promise.all(privateGroupsRaw.map(async g => {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
|
if (g.is_direct) {
|
||||||
// Ensure current user is still a member (may have left)
|
if (!g.direct_peer1_id || !g.direct_peer2_id) {
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
|
const peers = await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 2', [g.id]);
|
||||||
// Re-set readonly to false so both can post again
|
if (peers.length === 2) {
|
||||||
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
|
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]);
|
||||||
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.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)
|
// Check for duplicate private group
|
||||||
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
|
if ((type === 'private' || !type) && !isDirect && memberIds?.length > 0) {
|
||||||
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
|
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a,b) => a-b);
|
||||||
|
const candidates = await query(req.schema,
|
||||||
const result = db.prepare(`
|
'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',
|
||||||
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
|
[req.user.id]
|
||||||
VALUES (?, 'private', NULL, 0, 1, ?, ?)
|
);
|
||||||
`).run(dmName, userId, otherUserId);
|
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);
|
||||||
const groupId = result.lastInsertRowid;
|
if (members.length === allMemberIds.length && members.every((id,i) => id === allMemberIds[i]))
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
|
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [c.id]), duplicate: true });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
const r = await queryResult(req.schema,
|
||||||
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
|
'INSERT INTO groups (name,type,owner_id,is_readonly,is_direct) VALUES ($1,$2,$3,$4,FALSE) RETURNING id',
|
||||||
VALUES (?, ?, ?, ?, 0)
|
[name, type||'private', req.user.id, !!isReadonly]
|
||||||
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
|
);
|
||||||
|
const groupId = r.rows[0].id;
|
||||||
const groupId = result.lastInsertRowid;
|
if (type === 'public') {
|
||||||
|
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||||
if (type === 'public') {
|
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]);
|
||||||
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
} else {
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, req.user.id]);
|
||||||
for (const u of allUsers) insert.run(groupId, u.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]);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
await emitGroupNew(req.schema, io, groupId);
|
||||||
|
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|
||||||
// Notify all members via socket
|
|
||||||
emitGroupNew(io, groupId);
|
|
||||||
|
|
||||||
res.json({ group });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename group
|
// PATCH rename
|
||||||
router.patch('/:id/rename', authMiddleware, (req, res) => {
|
router.patch('/:id/rename', authMiddleware, async (req, res) => {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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) 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_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.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 === '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') {
|
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can rename' });
|
||||||
return res.status(403).json({ error: 'Only owner can rename private group' });
|
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);
|
||||||
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
|
res.json({ success: true });
|
||||||
emitGroupUpdated(io, group.id);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get group members
|
// GET members
|
||||||
router.get('/:id/members', authMiddleware, (req, res) => {
|
router.get('/:id/members', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const members = db.prepare(`
|
const members = await query(req.schema,
|
||||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
|
'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',
|
||||||
FROM group_members gm
|
[req.params.id]
|
||||||
JOIN users u ON gm.user_id = u.id
|
);
|
||||||
WHERE gm.group_id = ?
|
res.json({ members });
|
||||||
ORDER BY u.name ASC
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
`).all(req.params.id);
|
|
||||||
res.json({ members });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add member to private group
|
// POST add member
|
||||||
router.post('/:id/members', authMiddleware, (req, res) => {
|
router.post('/:id/members', authMiddleware, async (req, res) => {
|
||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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) 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.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.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') {
|
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' });
|
||||||
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]);
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
|
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
||||||
|
const mr = await queryResult(req.schema,
|
||||||
// Post a system message so all members see who was added
|
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||||
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
[group.id, userId, `${addedName} has joined the conversation.`]
|
||||||
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
);
|
||||||
const sysResult = db.prepare(`
|
const sysMsg = await queryOne(req.schema,
|
||||||
INSERT INTO messages (group_id, user_id, content, type)
|
'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',
|
||||||
VALUES (?, ?, ?, 'system')
|
[mr.rows[0].id]
|
||||||
`).run(group.id, userId, `${addedName} has joined the conversation.`);
|
);
|
||||||
const sysMsg = db.prepare(`
|
sysMsg.reactions = [];
|
||||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
|
||||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
io.to(`user:${userId}`).emit('group:new', { group });
|
||||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
res.json({ success: true });
|
||||||
`).get(sysResult.lastInsertRowid);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a member from a private group
|
// DELETE remove member
|
||||||
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
|
router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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) 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.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') {
|
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||||
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 targetId = parseInt(req.params.userId);
|
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
|
||||||
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
|
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 removedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(targetId);
|
const mr = await queryResult(req.schema,
|
||||||
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
|
"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.`]
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
|
);
|
||||||
|
const sysMsg = await queryOne(req.schema,
|
||||||
// Post system message so remaining members see the removal notice
|
'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',
|
||||||
const sysResult = db.prepare(`
|
[mr.rows[0].id]
|
||||||
INSERT INTO messages (group_id, user_id, content, type)
|
);
|
||||||
VALUES (?, ?, ?, 'system')
|
sysMsg.reactions = [];
|
||||||
`).run(group.id, targetId, `${removedName} has been removed from the conversation.`);
|
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||||
const sysMsg = db.prepare(`
|
io.in(`user:${targetId}`).socketsLeave(`group:${group.id}`);
|
||||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
io.to(`user:${targetId}`).emit('group:deleted', { groupId: group.id });
|
||||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
res.json({ success: true });
|
||||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Leave private group
|
// DELETE leave
|
||||||
router.delete('/:id/leave', authMiddleware, (req, res) => {
|
router.delete('/:id/leave', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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) 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.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.' });
|
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 userId = req.user.id;
|
const leaverName = req.user.display_name || req.user.name;
|
||||||
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,
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
|
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||||
|
[group.id, userId, `${leaverName} has left the conversation.`]
|
||||||
// Post a system message so remaining members see the leave notice
|
);
|
||||||
const sysResult = db.prepare(`
|
const sysMsg = await queryOne(req.schema,
|
||||||
INSERT INTO messages (group_id, user_id, content, type)
|
'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',
|
||||||
VALUES (?, ?, ?, 'system')
|
[mr.rows[0].id]
|
||||||
`).run(group.id, userId, `${leaverName} has left the conversation.`);
|
);
|
||||||
|
sysMsg.reactions = [];
|
||||||
const sysMsg = db.prepare(`
|
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
io.in(`user:${userId}`).socketsLeave(`group:${group.id}`);
|
||||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
|
||||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
if (group.is_direct) {
|
||||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]);
|
||||||
`).get(sysResult.lastInsertRowid);
|
if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin take ownership
|
// POST take-ownership
|
||||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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.' });
|
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);
|
await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [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);
|
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 });
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete group
|
// DELETE group
|
||||||
router.delete('/:id', authMiddleware, (req, res) => {
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
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) 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.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 === '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') {
|
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' });
|
||||||
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
|
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'");
|
||||||
// Collect members before deleting
|
for (const u of all) if (!members.includes(u.id)) members.push(u.id);
|
||||||
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
|
const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]);
|
||||||
if (group.type === 'public') {
|
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]);
|
||||||
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
for (const msg of imageMessages) deleteImageFile(msg.image_url);
|
||||||
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
|
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 }); }
|
||||||
// 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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH custom-name
|
||||||
// Set or update user's custom name for a group
|
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
|
||||||
router.patch('/:id/custom-name', authMiddleware, (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const groupId = parseInt(req.params.id);
|
|
||||||
const userId = req.user.id;
|
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
const groupId = parseInt(req.params.id), userId = req.user.id;
|
||||||
if (!name || !name.trim()) {
|
try {
|
||||||
// Empty name = remove custom name (revert to owner name)
|
if (!name?.trim()) {
|
||||||
db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId);
|
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 });
|
return res.json({ success: true, name: null });
|
||||||
}
|
}
|
||||||
|
await exec(req.schema,
|
||||||
db.prepare(`
|
'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',
|
||||||
INSERT INTO user_group_names (user_id, group_id, name)
|
[userId, groupId, name.trim()]
|
||||||
VALUES (?, ?, ?)
|
);
|
||||||
ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name
|
res.json({ success: true, name: name.trim() });
|
||||||
`).run(userId, groupId, name.trim());
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|
||||||
res.json({ success: true, name: name.trim() });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const fs = require('fs');
|
||||||
const fs = require('fs');
|
const path = require('path');
|
||||||
const path = require('path');
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { exec, queryOne } = require('../models/db');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
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');
|
const HELP_FILE = path.join(__dirname, '../data/help.md');
|
||||||
|
|
||||||
// GET /api/help — returns markdown content
|
|
||||||
router.get('/', authMiddleware, (req, res) => {
|
router.get('/', authMiddleware, (req, res) => {
|
||||||
let content = '';
|
let content = '';
|
||||||
const filePath = HELP_FILE;
|
try { content = fs.readFileSync(HELP_FILE, 'utf8'); }
|
||||||
try {
|
catch (e) { content = '# Getting Started\n\nHelp content is not available yet.'; }
|
||||||
content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
} catch (e) {
|
|
||||||
content = '# Getting Started\n\nHelp content is not available yet.';
|
|
||||||
}
|
|
||||||
res.json({ content });
|
res.json({ content });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/help/status — returns whether user has dismissed help
|
router.get('/status', authMiddleware, async (req, res) => {
|
||||||
router.get('/status', authMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const user = await queryOne(req.schema, 'SELECT help_dismissed FROM users WHERE id = $1', [req.user.id]);
|
||||||
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
|
res.json({ dismissed: !!user?.help_dismissed });
|
||||||
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, async (req, res) => {
|
||||||
router.post('/dismiss', authMiddleware, (req, res) => {
|
|
||||||
const { dismissed } = req.body;
|
const { dismissed } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
|
await exec(req.schema, 'UPDATE users SET help_dismissed = $1 WHERE id = $2', [!!dismissed, req.user.id]);
|
||||||
.run(dismissed ? 1 : 0, req.user.id);
|
res.json({ success: true });
|
||||||
res.json({ success: true });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
312
backend/src/routes/host.js
Normal file
312
backend/src/routes/host.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* routes/host.js — JAMA-HOST control plane
|
||||||
|
*
|
||||||
|
* All routes require the HOST_ADMIN_KEY header.
|
||||||
|
* These routes operate on the 'public' schema (tenant registry).
|
||||||
|
* They provision/deprovision per-tenant schemas.
|
||||||
|
*
|
||||||
|
* APP_TYPE must be 'host' for these routes to be registered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const {
|
||||||
|
query, queryOne, queryResult, exec,
|
||||||
|
runMigrations, ensureSchema,
|
||||||
|
seedSettings, seedEventTypes, seedAdmin,
|
||||||
|
refreshTenantCache,
|
||||||
|
} = require('../models/db');
|
||||||
|
|
||||||
|
const HOST_ADMIN_KEY = process.env.HOST_ADMIN_KEY || '';
|
||||||
|
|
||||||
|
// ── Host admin key guard ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hostAdminMiddleware(req, res, next) {
|
||||||
|
if (!HOST_ADMIN_KEY) {
|
||||||
|
return res.status(503).json({ error: 'HOST_ADMIN_KEY is not configured' });
|
||||||
|
}
|
||||||
|
const key = req.headers['x-host-admin-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
||||||
|
if (!key || key !== HOST_ADMIN_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Invalid host admin key' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All routes in this file require the host admin key
|
||||||
|
router.use(hostAdminMiddleware);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function slugToSchema(slug) {
|
||||||
|
return `tenant_${slug.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSlug(slug) {
|
||||||
|
return /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadTenantCache() {
|
||||||
|
const tenants = await query('public', "SELECT * FROM tenants WHERE status = 'active'");
|
||||||
|
refreshTenantCache(tenants);
|
||||||
|
return tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/host/tenants — list all tenants ──────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/tenants', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenants = await query('public',
|
||||||
|
'SELECT * FROM tenants ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
res.json({ tenants });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/host/tenants/:slug — get single tenant ───────────────────────────
|
||||||
|
|
||||||
|
router.get('/tenants/:slug', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenant = await queryOne('public',
|
||||||
|
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||||
|
);
|
||||||
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
res.json({ tenant });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/host/tenants — provision a new tenant ───────────────────────────
|
||||||
|
//
|
||||||
|
// Body: { slug, name, plan, adminEmail, adminName, adminPass, customDomain? }
|
||||||
|
//
|
||||||
|
// This:
|
||||||
|
// 1. Validates the slug (becomes subdomain + schema name)
|
||||||
|
// 2. Creates the Postgres schema
|
||||||
|
// 3. Runs all migrations in the new schema
|
||||||
|
// 4. Seeds settings, event types, and the first admin user
|
||||||
|
// 5. Records the tenant in the registry
|
||||||
|
// 6. Reloads the tenant domain cache
|
||||||
|
|
||||||
|
router.post('/tenants', async (req, res) => {
|
||||||
|
const { slug, name, plan, adminEmail, adminName, adminPass, customDomain } = req.body;
|
||||||
|
|
||||||
|
if (!slug || !name) return res.status(400).json({ error: 'slug and name are required' });
|
||||||
|
if (!isValidSlug(slug)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'slug must be 3-32 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaName = slugToSchema(slug);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check slug not already taken
|
||||||
|
const existing = await queryOne('public',
|
||||||
|
'SELECT id FROM tenants WHERE slug = $1', [slug]
|
||||||
|
);
|
||||||
|
if (existing) return res.status(400).json({ error: `Tenant '${slug}' already exists` });
|
||||||
|
|
||||||
|
if (customDomain) {
|
||||||
|
const domainTaken = await queryOne('public',
|
||||||
|
'SELECT id FROM tenants WHERE custom_domain = $1', [customDomain.toLowerCase()]
|
||||||
|
);
|
||||||
|
if (domainTaken) return res.status(400).json({ error: `Custom domain '${customDomain}' is already in use` });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Host] Provisioning tenant: ${slug} (schema: ${schemaName})`);
|
||||||
|
|
||||||
|
// 1. Create schema + run migrations
|
||||||
|
await runMigrations(schemaName);
|
||||||
|
|
||||||
|
// 2. Seed settings (uses env defaults unless overridden by body)
|
||||||
|
await seedSettings(schemaName);
|
||||||
|
|
||||||
|
// 3. Seed event types
|
||||||
|
await seedEventTypes(schemaName);
|
||||||
|
|
||||||
|
// 4. Seed admin user — temporarily override env vars for this tenant
|
||||||
|
const origEmail = process.env.ADMIN_EMAIL;
|
||||||
|
const origName = process.env.ADMIN_NAME;
|
||||||
|
const origPass = process.env.ADMIN_PASS;
|
||||||
|
if (adminEmail) process.env.ADMIN_EMAIL = adminEmail;
|
||||||
|
if (adminName) process.env.ADMIN_NAME = adminName;
|
||||||
|
if (adminPass) process.env.ADMIN_PASS = adminPass;
|
||||||
|
|
||||||
|
await seedAdmin(schemaName);
|
||||||
|
|
||||||
|
process.env.ADMIN_EMAIL = origEmail;
|
||||||
|
process.env.ADMIN_NAME = origName;
|
||||||
|
process.env.ADMIN_PASS = origPass;
|
||||||
|
|
||||||
|
// 5. Set app_type based on plan
|
||||||
|
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
|
||||||
|
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||||
|
if (plan === 'brand' || plan === 'team') {
|
||||||
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
|
||||||
|
}
|
||||||
|
if (plan === 'team') {
|
||||||
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_group_manager'");
|
||||||
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_schedule_manager'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Register in tenants table
|
||||||
|
const tr = await queryResult('public', `
|
||||||
|
INSERT INTO tenants (slug, name, schema_name, custom_domain, plan, admin_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
`, [slug, name, schemaName, customDomain?.toLowerCase() || null, plan || 'chat', adminEmail || null]);
|
||||||
|
|
||||||
|
// 7. Reload domain cache
|
||||||
|
await reloadTenantCache();
|
||||||
|
|
||||||
|
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
|
||||||
|
const tenant = tr.rows[0];
|
||||||
|
tenant.url = `https://${slug}.${baseDomain}`;
|
||||||
|
|
||||||
|
console.log(`[Host] Tenant provisioned: ${slug} → ${schemaName}`);
|
||||||
|
res.status(201).json({ tenant });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Host] Provisioning failed for ${slug}:`, e.message);
|
||||||
|
// Attempt cleanup of partially-created schema
|
||||||
|
try {
|
||||||
|
await exec('public', `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
||||||
|
console.log(`[Host] Cleaned up schema ${schemaName} after failed provision`);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error(`[Host] Cleanup failed:`, cleanupErr.message);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH /api/host/tenants/:slug — update tenant ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Supports updating: name, plan, customDomain, status
|
||||||
|
|
||||||
|
router.patch('/tenants/:slug', async (req, res) => {
|
||||||
|
const { name, plan, customDomain, status } = req.body;
|
||||||
|
try {
|
||||||
|
const tenant = await queryOne('public',
|
||||||
|
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||||
|
);
|
||||||
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
|
||||||
|
if (customDomain && customDomain !== tenant.custom_domain) {
|
||||||
|
const taken = await queryOne('public',
|
||||||
|
'SELECT id FROM tenants WHERE custom_domain=$1 AND slug!=$2',
|
||||||
|
[customDomain.toLowerCase(), req.params.slug]
|
||||||
|
);
|
||||||
|
if (taken) return res.status(400).json({ error: 'Custom domain already in use' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && !['active','suspended'].includes(status))
|
||||||
|
return res.status(400).json({ error: 'status must be active or suspended' });
|
||||||
|
|
||||||
|
await exec('public', `
|
||||||
|
UPDATE tenants SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
plan = COALESCE($2, plan),
|
||||||
|
custom_domain = $3,
|
||||||
|
status = COALESCE($4, status),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE slug = $5
|
||||||
|
`, [name || null, plan || null, customDomain?.toLowerCase() ?? tenant.custom_domain, status || null, req.params.slug]);
|
||||||
|
|
||||||
|
// If plan changed, update feature flags in tenant schema
|
||||||
|
if (plan && plan !== tenant.plan) {
|
||||||
|
const s = tenant.schema_name;
|
||||||
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
|
||||||
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
|
||||||
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
|
||||||
|
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
|
||||||
|
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reloadTenantCache();
|
||||||
|
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||||
|
res.json({ tenant: updated });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE /api/host/tenants/:slug — deprovision tenant ───────────────────────
|
||||||
|
//
|
||||||
|
// Permanently drops the tenant's Postgres schema and all data.
|
||||||
|
// Requires confirmation: body must include { confirm: "DELETE {slug}" }
|
||||||
|
|
||||||
|
router.delete('/tenants/:slug', async (req, res) => {
|
||||||
|
const { confirm } = req.body;
|
||||||
|
if (confirm !== `DELETE ${req.params.slug}`) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Confirmation required. Send { "confirm": "DELETE ${req.params.slug}" } in the request body.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenant = await queryOne('public',
|
||||||
|
'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]
|
||||||
|
);
|
||||||
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
|
||||||
|
console.log(`[Host] Deprovisioning tenant: ${req.params.slug} (schema: ${tenant.schema_name})`);
|
||||||
|
|
||||||
|
// Drop the entire schema — CASCADE removes all tables, indexes, triggers
|
||||||
|
await exec('public', `DROP SCHEMA IF EXISTS "${tenant.schema_name}" CASCADE`);
|
||||||
|
|
||||||
|
// Remove from registry
|
||||||
|
await exec('public', 'DELETE FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||||
|
|
||||||
|
await reloadTenantCache();
|
||||||
|
|
||||||
|
console.log(`[Host] Tenant deprovisioned: ${req.params.slug}`);
|
||||||
|
res.json({ success: true, message: `Tenant '${req.params.slug}' and all its data have been permanently deleted.` });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/host/tenants/:slug/migrate — run pending migrations ─────────────
|
||||||
|
//
|
||||||
|
// Useful after deploying a new migration file to apply it to all tenants.
|
||||||
|
|
||||||
|
router.post('/tenants/:slug/migrate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||||
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
await runMigrations(tenant.schema_name);
|
||||||
|
const applied = await query(tenant.schema_name, 'SELECT * FROM schema_migrations ORDER BY version');
|
||||||
|
res.json({ success: true, migrations: applied });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/host/migrate-all — run pending migrations on every tenant ───────
|
||||||
|
|
||||||
|
router.post('/migrate-all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
||||||
|
const results = [];
|
||||||
|
for (const t of tenants) {
|
||||||
|
try {
|
||||||
|
await runMigrations(t.schema_name);
|
||||||
|
results.push({ slug: t.slug, status: 'ok' });
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ slug: t.slug, status: 'error', error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ results });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/host/status — host health check ──────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
|
||||||
|
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
|
||||||
|
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
appType: process.env.APP_TYPE || 'selfhost',
|
||||||
|
baseDomain,
|
||||||
|
tenants: { total: parseInt(tenantCount.count), active: parseInt(active.count) },
|
||||||
|
});
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,219 +1,173 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { getDb } = require('../models/db');
|
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) {
|
function deleteImageFile(imageUrl) {
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
try {
|
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||||
const filePath = '/app' + imageUrl; // imageUrl is like /uploads/images/img_xxx.jpg
|
catch (e) { console.warn('[Messages] Could not delete image:', e.message); }
|
||||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Messages] Could not delete image file:', e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function(io) {
|
module.exports = function(io) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
const imgStorage = multer.diskStorage({
|
const imgStorage = multer.diskStorage({
|
||||||
destination: '/app/uploads/images',
|
destination: '/app/uploads/images',
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2,6)}${path.extname(file.originalname)}`),
|
||||||
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) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||||
});
|
});
|
||||||
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'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function getUserForMessage(db, userId) {
|
async function canAccessGroup(schema, groupId, userId) {
|
||||||
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
|
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||||
}
|
if (!group) return null;
|
||||||
|
if (group.type === 'public') return group;
|
||||||
function canAccessGroup(db, groupId, userId) {
|
const member = await queryOne(schema, 'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2', [groupId, userId]);
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
return member ? group : null;
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = `
|
// GET messages for group
|
||||||
SELECT m.*,
|
router.get('/group/:groupId', authMiddleware, async (req, res) => {
|
||||||
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,
|
try {
|
||||||
rm.content as reply_content, rm.image_url as reply_image_url,
|
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||||
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
|
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||||
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];
|
|
||||||
|
|
||||||
// Enforce join-date visibility for managed groups
|
const { before, limit = 50 } = req.query;
|
||||||
if (joinedAt) {
|
let joinedAt = null;
|
||||||
query += ` AND date(m.created_at) >= ?`;
|
if (group.is_managed) {
|
||||||
params.push(joinedAt);
|
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) {
|
let sql = `
|
||||||
query += ' AND m.id < ?';
|
SELECT m.*,
|
||||||
params.push(before);
|
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 ?';
|
const messages = await query(req.schema, sql, params);
|
||||||
params.push(parseInt(limit));
|
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
|
// POST image message
|
||||||
for (const msg of messages) {
|
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), async (req, res) => {
|
||||||
msg.reactions = db.prepare(`
|
try {
|
||||||
SELECT r.emoji, r.user_id, u.name as user_name
|
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||||
FROM reactions r JOIN users u ON r.user_id = u.id
|
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||||
WHERE r.message_id = ?
|
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
|
||||||
`).all(msg.id);
|
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
|
// POST reaction
|
||||||
router.post('/group/:groupId', authMiddleware, (req, res) => {
|
router.post('/:id/reactions', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
const { emoji } = req.body;
|
||||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
try {
|
||||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
const message = await queryOne(req.schema, 'SELECT * FROM messages WHERE id=$1 AND is_deleted=FALSE', [req.params.id]);
|
||||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
|
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;
|
return router;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,104 +1,112 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const webpush = require('web-push');
|
const webpush = require('web-push');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
// Get or generate VAPID keys stored in settings
|
// VAPID keys are stored in settings; lazily initialised on first request
|
||||||
function getVapidKeys() {
|
let vapidPublicKey = null;
|
||||||
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();
|
|
||||||
|
|
||||||
|
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) {
|
if (!pub?.value || !priv?.value) {
|
||||||
const keys = webpush.generateVAPIDKeys();
|
const keys = webpush.generateVAPIDKeys();
|
||||||
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
await exec(schema,
|
||||||
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||||
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
[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');
|
console.log('[Push] Generated new VAPID keys');
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
return { publicKey: pub.value, privateKey: priv.value };
|
return { publicKey: pub.value, privateKey: priv.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWebPush() {
|
async function initWebPush(schema) {
|
||||||
const keys = getVapidKeys();
|
const keys = await getVapidKeys(schema);
|
||||||
webpush.setVapidDetails(
|
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
||||||
'mailto:admin@jama.local',
|
|
||||||
keys.publicKey,
|
|
||||||
keys.privateKey
|
|
||||||
);
|
|
||||||
return keys.publicKey;
|
return keys.publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in index.js
|
// Called from index.js socket push notifications — schema comes from caller
|
||||||
let vapidPublicKey = null;
|
async function sendPushToUser(schema, userId, payload) {
|
||||||
function getVapidPublicKey() {
|
try {
|
||||||
if (!vapidPublicKey) vapidPublicKey = initWebPush();
|
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
|
||||||
return vapidPublicKey;
|
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
|
||||||
}
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
// Send a push notification to all subscriptions for a user
|
await webpush.sendNotification(
|
||||||
async function sendPushToUser(userId, payload) {
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
const db = getDb();
|
JSON.stringify(payload)
|
||||||
getVapidPublicKey(); // ensure webpush is configured
|
);
|
||||||
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
|
} catch (err) {
|
||||||
for (const sub of subs) {
|
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||||
try {
|
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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', async (req, res) => {
|
||||||
router.get('/vapid-public', (req, res) => {
|
try {
|
||||||
res.json({ publicKey: getVapidPublicKey() });
|
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, async (req, res) => {
|
||||||
router.post('/subscribe', authMiddleware, (req, res) => {
|
|
||||||
const { endpoint, keys } = req.body;
|
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' });
|
return res.status(400).json({ error: 'Invalid subscription' });
|
||||||
}
|
try {
|
||||||
const db = getDb();
|
const device = req.device || 'desktop';
|
||||||
const device = req.device || 'desktop';
|
await exec(req.schema,
|
||||||
// Delete any existing subscription for this user+device or this endpoint, then insert fresh
|
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
|
||||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? OR (user_id = ? AND device = ?)').run(endpoint, req.user.id, device);
|
[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 });
|
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, async (req, res) => {
|
||||||
router.post('/generate-vapid', authMiddleware, (req, res) => {
|
|
||||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
|
||||||
const db = getDb();
|
try {
|
||||||
const keys = webpush.generateVAPIDKeys();
|
const keys = webpush.generateVAPIDKeys();
|
||||||
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
await exec(req.schema,
|
||||||
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||||
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
[keys.publicKey]
|
||||||
// Reinitialise webpush with new keys immediately
|
);
|
||||||
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
await exec(req.schema,
|
||||||
vapidPublicKey = keys.publicKey;
|
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||||
console.log('[Push] VAPID keys regenerated by admin');
|
[keys.privateKey]
|
||||||
res.json({ publicKey: keys.publicKey });
|
);
|
||||||
|
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, async (req, res) => {
|
||||||
router.post('/unsubscribe', authMiddleware, (req, res) => {
|
|
||||||
const { endpoint } = req.body;
|
const { endpoint } = req.body;
|
||||||
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
|
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
|
||||||
const db = getDb();
|
try {
|
||||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
|
await exec(req.schema,
|
||||||
res.json({ success: true });
|
'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 };
|
||||||
|
|||||||
@@ -1,396 +1,378 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { parse: csvParse } = require('csv-parse/sync');
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function canViewEvent(db, event, userId, isToolManager) {
|
async function isToolManagerFn(schema, user) {
|
||||||
if (isToolManager) return true;
|
if (user.role === 'admin') return true;
|
||||||
if (event.is_public) return true;
|
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||||
// Private: user must be in an assigned user group
|
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
|
||||||
const assigned = db.prepare(`
|
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
|
SELECT 1 FROM event_user_groups eug
|
||||||
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
WHERE eug.event_id = ? AND ugm.user_id = ?
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
`).get(event.id, userId);
|
`, [event.id, userId]);
|
||||||
return !!assigned;
|
return !!assigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToolManagerFn(db, user) {
|
async function enrichEvent(schema, event) {
|
||||||
if (user.role === 'admin') return true;
|
event.event_type = event.event_type_id
|
||||||
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
|
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
|
||||||
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
|
: null;
|
||||||
const groupIds = [...new Set([
|
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
|
||||||
...JSON.parse(tmSetting?.value || '[]'),
|
event.user_groups = await query(schema, `
|
||||||
...JSON.parse(gmSetting?.value || '[]'),
|
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
|
||||||
if (!groupIds.length) return false;
|
`, [event.id]);
|
||||||
return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds);
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enrichEvent(db, event) {
|
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
|
||||||
event.event_type = event.event_type_id
|
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
|
||||||
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
|
await exec(schema, `
|
||||||
: null;
|
UPDATE events SET
|
||||||
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
|
title = COALESCE($1, title),
|
||||||
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
|
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 ───────────────────────────────────────────────────────────────
|
// ── Event Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/event-types', authMiddleware, (req, res) => {
|
router.get('/event-types', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() });
|
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;
|
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
const db = getDb();
|
try {
|
||||||
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
|
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' });
|
return res.status(400).json({ error: 'Event type with that name already exists' });
|
||||||
}
|
const r = await queryResult(req.schema,
|
||||||
const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs)
|
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||||
VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0);
|
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
|
||||||
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) });
|
);
|
||||||
|
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) => {
|
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
|
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) return res.status(404).json({ error: 'Not found' });
|
||||||
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
|
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
|
||||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||||
if (name && name.trim() !== et.name) {
|
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))
|
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' });
|
return res.status(400).json({ error: 'Name already in use' });
|
||||||
}
|
}
|
||||||
db.prepare(`UPDATE event_types SET
|
await exec(req.schema, `
|
||||||
name = COALESCE(?, name),
|
UPDATE event_types SET
|
||||||
colour = COALESCE(?, colour),
|
name = COALESCE($1, name),
|
||||||
default_user_group_id = ?,
|
colour = COALESCE($2, colour),
|
||||||
default_duration_hrs = COALESCE(?, default_duration_hrs)
|
default_user_group_id = $3,
|
||||||
WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id);
|
default_duration_hrs = COALESCE($4, default_duration_hrs)
|
||||||
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) });
|
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) => {
|
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
|
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) 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' });
|
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
|
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
|
||||||
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
|
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
|
||||||
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
|
res.json({ success: true });
|
||||||
res.json({ success: true });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────────
|
// ── Events ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// List events (with optional date range filter)
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
router.get('/', authMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
const itm = isToolManagerFn(db, req.user);
|
const { from, to } = req.query;
|
||||||
const { from, to } = req.query;
|
let sql = 'SELECT * FROM events WHERE 1=1';
|
||||||
let q = 'SELECT * FROM events WHERE 1=1';
|
const params = [];
|
||||||
const params = [];
|
let pi = 1;
|
||||||
if (from) { q += ' AND end_at >= ?'; params.push(from); }
|
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
|
||||||
if (to) { q += ' AND start_at <= ?'; params.push(to); }
|
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
|
||||||
q += ' ORDER BY start_at ASC';
|
sql += ' ORDER BY start_at ASC';
|
||||||
const events = db.prepare(q).all(...params)
|
const rawEvents = await query(req.schema, sql, params);
|
||||||
.filter(e => canViewEvent(db, e, req.user.id, itm))
|
const events = [];
|
||||||
.map(e => {
|
for (const e of rawEvents) {
|
||||||
enrichEvent(db, e);
|
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
|
||||||
// Include current user's response so the list can show the awaiting indicator
|
await enrichEvent(req.schema, e);
|
||||||
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(e.id, req.user.id);
|
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;
|
e.my_response = mine?.response || null;
|
||||||
return e;
|
events.push(e);
|
||||||
});
|
}
|
||||||
res.json({ events });
|
res.json({ events });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single event
|
router.get('/me/pending', authMiddleware, async (req, res) => {
|
||||||
router.get('/:id', authMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const pending = await query(req.schema, `
|
||||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
SELECT DISTINCT e.* FROM events e
|
||||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
JOIN event_user_groups eug ON eug.event_id=e.id
|
||||||
const itm = isToolManagerFn(db, req.user);
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' });
|
WHERE ugm.user_id=$1 AND e.track_availability=TRUE
|
||||||
enrichEvent(db, event);
|
AND e.end_at >= NOW()
|
||||||
// Availability (only for assigned group members / tool managers)
|
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
|
||||||
if (event.track_availability && itm) {
|
ORDER BY e.start_at ASC
|
||||||
const responses = db.prepare(`
|
`, [req.user.id]);
|
||||||
SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar
|
const result = [];
|
||||||
FROM event_availability ea JOIN users u ON u.id = ea.user_id
|
for (const e of pending) result.push(await enrichEvent(req.schema, e));
|
||||||
WHERE ea.event_id = ?
|
res.json({ events: result });
|
||||||
`).all(req.params.id);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event
|
router.get('/:id', authMiddleware, async (req, res) => {
|
||||||
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
try {
|
||||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
|
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 (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||||
const db = getDb();
|
try {
|
||||||
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)
|
const r = await queryResult(req.schema, `
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||||
title.trim(), eventTypeId || null, startAt, endAt,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||||
allDay ? 1 : 0, location || null, description || null,
|
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||||
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
|
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||||
recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
|
const eventId = r.rows[0].id;
|
||||||
);
|
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||||
const eventId = r.lastInsertRowid;
|
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||||
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId);
|
res.json({ event: await enrichEvent(req.schema, event) });
|
||||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json({ event: enrichEvent(db, event) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||||
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) return res.status(404).json({ error: 'Not found' });
|
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||||
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 };
|
||||||
db.prepare(`UPDATE events SET
|
|
||||||
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
|
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||||
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
|
|
||||||
location = ?, description = ?, is_public = COALESCE(?, is_public),
|
// Recurring future scope — update all future occurrences
|
||||||
track_availability = COALESCE(?, track_availability),
|
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||||
recurrence_rule = ?,
|
const futureEvents = await query(req.schema, `
|
||||||
updated_at = datetime('now')
|
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
||||||
WHERE id = ?`).run(
|
AND start_at >= $3 AND title=$4
|
||||||
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
`, [req.params.id, event.created_by, event.start_at, event.title]);
|
||||||
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
|
for (const fe of futureEvents)
|
||||||
location !== undefined ? (location || null) : event.location,
|
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(userGroupIds)) {
|
// Clean up availability for users removed from groups
|
||||||
// Find which groups are being removed
|
if (Array.isArray(userGroupIds)) {
|
||||||
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')
|
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);
|
||||||
.all(req.params.id).map(r => r.user_group_id);
|
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||||
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);
|
||||||
// Remove availability responses for users who are only in removed groups
|
for (const uid of removedUids) {
|
||||||
for (const removedGid of removedGroupIds) {
|
if (newGroupSet.size > 0) {
|
||||||
const removedUserIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?')
|
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||||
.all(removedGid).map(r => r.user_id);
|
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||||
for (const uid of removedUserIds) {
|
if (stillAssigned) continue;
|
||||||
// Check if user is still in ANY remaining group for this event
|
}
|
||||||
const stillAssigned = newGroupSet.size > 0 && db.prepare(`
|
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id);
|
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||||
for (const ugId of userGroupIds)
|
res.json({ event: await enrichEvent(req.schema, updated) });
|
||||||
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
}
|
|
||||||
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
|
||||||
res.json({ event: enrichEvent(db, updated) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete event
|
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
|
||||||
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' });
|
return res.status(404).json({ error: 'Not found' });
|
||||||
db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
|
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Availability ──────────────────────────────────────────────────────────────
|
// ── Availability ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Submit/update availability
|
router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||||
router.put('/:id/availability', authMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||||
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) return res.status(404).json({ error: 'Not found' });
|
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
||||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' });
|
const { response } = req.body;
|
||||||
const { response } = req.body;
|
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
// User must be in an assigned group
|
const inGroup = await queryOne(req.schema, `
|
||||||
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
|
||||||
SELECT 1 FROM event_user_groups eug
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
|
`, [event.id, req.user.id]);
|
||||||
WHERE eug.event_id = ? AND ugm.user_id = ?
|
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||||
`).get(event.id, req.user.id);
|
await exec(req.schema, `
|
||||||
const itm = isToolManagerFn(db, req.user);
|
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||||
db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
|
`, [event.id, req.user.id, response]);
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
res.json({ success: true, response });
|
||||||
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
`).run(event.id, req.user.id, response, response);
|
|
||||||
res.json({ success: true, response });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete availability (withdraw response)
|
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||||
router.delete('/:id/availability', authMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||||
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
res.json({ success: true });
|
||||||
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.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||||
router.get('/me/pending', authMiddleware, (req, res) => {
|
const { responses } = req.body;
|
||||||
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 }]
|
|
||||||
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
|
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
|
||||||
const db = getDb();
|
try {
|
||||||
const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
|
let saved = 0;
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`);
|
for (const { eventId, response } of responses) {
|
||||||
let saved = 0;
|
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||||
for (const { eventId, response } of responses) {
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
if (!event || !event.track_availability) continue;
|
||||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
|
const inGroup = await queryOne(req.schema, `
|
||||||
if (!event || !event.track_availability) continue;
|
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
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);
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
const itm = isToolManagerFn(db, req.user);
|
`, [eventId, req.user.id]);
|
||||||
if (!inGroup && !itm) continue;
|
if (!inGroup && !itm) continue;
|
||||||
stmt.run(eventId, req.user.id, response, response);
|
await exec(req.schema, `
|
||||||
saved++;
|
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()
|
||||||
res.json({ success: true, saved });
|
`, [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' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
try {
|
try {
|
||||||
const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true });
|
const rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
|
||||||
const db = getDb();
|
const results = await Promise.all(rows.map(async (row, i) => {
|
||||||
const results = rows.map((row, i) => {
|
|
||||||
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
|
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
|
||||||
const startDate = row['start_date'] || row['Start Date'] || '';
|
const startDate = row['start_date'] || row['Start Date'] || '';
|
||||||
const startTime = row['start_time'] || row['Start Time'] || '09:00';
|
const startTime = row['start_time'] || row['Start Time'] || '09:00';
|
||||||
const location = row['event_location'] || row['location'] || '';
|
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;
|
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 startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
|
||||||
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
|
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
|
||||||
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
|
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]);
|
||||||
// Check duplicate
|
return { row:i+1, title, startAt, endAt, location, typeName, durHrs, duplicate:!!dup, duplicateId:dup?.id, error:null };
|
||||||
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 };
|
|
||||||
});
|
|
||||||
res.json({ rows: results });
|
res.json({ rows: results });
|
||||||
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
|
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => {
|
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const { rows } = req.body; // filtered rows from preview (client excludes skipped)
|
const { rows } = req.body;
|
||||||
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
|
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
|
||||||
const db = getDb();
|
try {
|
||||||
let imported = 0;
|
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)
|
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
||||||
VALUES (?, ?, ?, ?, ?, 1, 0, ?)`);
|
for (const row of rows) {
|
||||||
for (const row of rows) {
|
if (row.error || row.skip) continue;
|
||||||
if (row.error || row.skip) continue;
|
let typeId = null;
|
||||||
let typeId = null;
|
if (row.typeName) {
|
||||||
if (row.typeName) {
|
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
|
||||||
let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName);
|
if (!et) {
|
||||||
if (!et) {
|
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
|
||||||
// Create missing type with random colour
|
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
|
||||||
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
|
||||||
const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour);
|
typeId = cr.rows[0].id;
|
||||||
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
|
} else { typeId = et.id; }
|
||||||
const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour);
|
}
|
||||||
typeId = r2.lastInsertRowid;
|
await exec(req.schema,
|
||||||
} else { typeId = et.id; }
|
'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);
|
res.json({ success: true, imported });
|
||||||
imported++;
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
}
|
|
||||||
res.json({ success: true, imported });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,190 +1,148 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, exec } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
// Generic icon storage factory
|
|
||||||
function makeIconStorage(prefix) {
|
function makeIconStorage(prefix) {
|
||||||
return multer.diskStorage({
|
return multer.diskStorage({
|
||||||
destination: '/app/uploads/logos',
|
destination: '/app/uploads/logos',
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => cb(null, `${prefix}_${Date.now()}${path.extname(file.originalname)}`),
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `${prefix}_${Date.now()}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const iconOpts = {
|
||||||
const iconUploadOpts = {
|
|
||||||
limits: { fileSize: 1 * 1024 * 1024 },
|
limits: { fileSize: 1 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
|
||||||
else 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 });
|
// Helper: upsert a setting
|
||||||
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
|
async function setSetting(schema, key, value) {
|
||||||
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
|
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)
|
// GET /api/settings
|
||||||
router.get('/', (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const settings = db.prepare('SELECT key, value FROM settings').all();
|
const rows = await query(req.schema, 'SELECT key, value FROM settings');
|
||||||
const obj = {};
|
const obj = {};
|
||||||
for (const s of settings) obj[s.key] = s.value;
|
for (const r of rows) obj[r.key] = r.value;
|
||||||
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
||||||
if (admin) obj.admin_email = admin.email;
|
if (admin) obj.admin_email = admin.email;
|
||||||
// Expose app version from Docker build arg env var
|
obj.app_version = process.env.JAMA_VERSION || 'dev';
|
||||||
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
|
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||||
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
res.json({ settings: obj });
|
||||||
res.json({ settings: obj });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update app name (admin)
|
router.patch('/app-name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
|
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
const db = getDb();
|
try {
|
||||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
|
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() });
|
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) => {
|
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||||
|
|
||||||
const logoUrl = `/uploads/logos/${req.file.filename}`;
|
const logoUrl = `/uploads/logos/${req.file.filename}`;
|
||||||
const srcPath = req.file.path;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate PWA icons from the uploaded logo
|
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');
|
||||||
const icon192Path = '/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');
|
||||||
const icon512Path = '/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 sharp(srcPath)
|
await setSetting(req.schema, 'pwa_icon_512', '/uploads/logos/pwa-icon-512.png');
|
||||||
.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');
|
|
||||||
|
|
||||||
res.json({ logoUrl });
|
res.json({ logoUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Logo] Failed to generate PWA icons:', err.message);
|
console.error('[Logo] icon gen failed:', err.message);
|
||||||
// Still save the logo even if icon generation fails
|
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
|
||||||
const db = getDb();
|
|
||||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
|
|
||||||
res.json({ logoUrl });
|
res.json({ logoUrl });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload New Chat icon (admin)
|
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), async (req, res) => {
|
||||||
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||||
const db = getDb();
|
try { await setSetting(req.schema, 'icon_newchat', iconUrl); res.json({ iconUrl }); }
|
||||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
.run(iconUrl, iconUrl);
|
|
||||||
res.json({ iconUrl });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload Group Info icon (admin)
|
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), async (req, res) => {
|
||||||
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||||
const db = getDb();
|
try { await setSetting(req.schema, 'icon_groupinfo', iconUrl); res.json({ iconUrl }); }
|
||||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
.run(iconUrl, iconUrl);
|
|
||||||
res.json({ iconUrl });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset all settings to defaults (admin)
|
router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
|
|
||||||
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
|
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
if (colorTitle !== undefined) await setSetting(req.schema, 'color_title', colorTitle || '');
|
||||||
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
|
if (colorTitleDark !== undefined) await setSetting(req.schema, 'color_title_dark', colorTitleDark || '');
|
||||||
if (colorTitleDark !== undefined) upd.run('color_title_dark', colorTitleDark || '', colorTitleDark || '');
|
if (colorAvatarPublic !== undefined) await setSetting(req.schema, 'color_avatar_public', colorAvatarPublic || '');
|
||||||
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
|
if (colorAvatarDm !== undefined) await setSetting(req.schema, 'color_avatar_dm', colorAvatarDm || '');
|
||||||
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
|
res.json({ success: true });
|
||||||
res.json({ success: true });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const originalName = process.env.APP_NAME || 'jama';
|
const originalName = process.env.APP_NAME || 'jama';
|
||||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
|
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
|
||||||
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
|
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
|
||||||
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();
|
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 });
|
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 = {
|
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-TEAM-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
|
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
|
||||||
// JAMA-Brand: chat + branding only
|
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||||
'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 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post('/register', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
if (!code?.trim()) {
|
||||||
|
await setSetting(req.schema, 'registration_code', '');
|
||||||
if (!code?.trim()) {
|
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
|
||||||
// Clear registration
|
await setSetting(req.schema, 'feature_branding', 'false');
|
||||||
upd.run('registration_code', '', '');
|
await setSetting(req.schema, 'feature_group_manager', 'false');
|
||||||
upd.run('app_type', 'JAMA-Chat', 'JAMA-Chat');
|
await setSetting(req.schema, 'feature_schedule_manager', 'false');
|
||||||
upd.run('feature_branding', 'false', 'false');
|
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
|
||||||
upd.run('feature_group_manager', 'false', 'false');
|
}
|
||||||
upd.run('feature_schedule_manager', 'false', 'false');
|
const match = VALID_CODES[code.trim().toUpperCase()];
|
||||||
return res.json({ success: true, features: { branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat' } });
|
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);
|
||||||
const match = VALID_CODES[code.trim().toUpperCase()];
|
await setSetting(req.schema, 'feature_branding', match.branding ? 'true' : 'false');
|
||||||
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
|
await setSetting(req.schema, 'feature_group_manager', match.groupManager ? 'true' : 'false');
|
||||||
|
await setSetting(req.schema, 'feature_schedule_manager', match.scheduleManager ? 'true' : 'false');
|
||||||
upd.run('registration_code', code.trim(), code.trim());
|
res.json({ success:true, features:{ branding:match.branding, groupManager:match.groupManager, scheduleManager:match.scheduleManager, appType:match.appType } });
|
||||||
upd.run('app_type', match.appType || 'JAMA-Chat', match.appType || 'JAMA-Chat');
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save team management group assignments
|
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
router.patch('/team', authMiddleware, adminMiddleware, (req, res) => {
|
|
||||||
const { toolManagers } = req.body;
|
const { toolManagers } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
if (toolManagers !== undefined) {
|
||||||
if (toolManagers !== undefined) {
|
const val = JSON.stringify(toolManagers || []);
|
||||||
const val = JSON.stringify(toolManagers || []);
|
await setSetting(req.schema, 'team_tool_managers', val);
|
||||||
upd.run('team_tool_managers', val, val);
|
await setSetting(req.schema, 'team_group_managers', val);
|
||||||
// Keep legacy keys in sync so existing teamManagerMiddleware still works
|
await setSetting(req.schema, 'team_schedule_managers', val);
|
||||||
upd.run('team_group_managers', val, val);
|
}
|
||||||
upd.run('team_schedule_managers', val, val);
|
res.json({ success: true });
|
||||||
}
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,302 +1,313 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../models/db');
|
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
module.exports = function(io) {
|
module.exports = function(io) {
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function postSysMsg(db, groupId, actorId, content) {
|
async function postSysMsg(schema, groupId, actorId, content) {
|
||||||
const r = db.prepare(`INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system')`).run(groupId, actorId, content);
|
const r = await queryResult(schema,
|
||||||
const msg = db.prepare(`
|
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
[groupId, actorId, content]
|
||||||
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
|
const msg = await queryOne(schema, `
|
||||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
|
||||||
`).get(r.lastInsertRowid);
|
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); }
|
if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user silently — no system message (used during initial creation)
|
async function addUserSilent(schema, dmGroupId, userId) {
|
||||||
function addUserSilent(db, dmGroupId, userId) {
|
await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]);
|
||||||
db.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id, joined_at) VALUES (?, ?, datetime('now'))").run(dmGroupId, userId);
|
|
||||||
io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`);
|
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 });
|
if (dmGroup) io.to(`user:${userId}`).emit('group:new', { group: dmGroup });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user with system message (used when editing existing group)
|
async function addUser(schema, dmGroupId, userId, actorId) {
|
||||||
function addUser(db, dmGroupId, userId, actorId) {
|
await addUserSilent(schema, dmGroupId, userId);
|
||||||
addUserSilent(db, dmGroupId, userId);
|
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||||
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||||
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has joined the conversation.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove user with system message
|
async function removeUser(schema, dmGroupId, userId, actorId) {
|
||||||
function removeUser(db, dmGroupId, userId, actorId) {
|
await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]);
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(dmGroupId, userId);
|
|
||||||
io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
|
io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
|
||||||
io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
|
io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
|
||||||
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||||
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`);
|
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserIdsForGroup(db, userGroupId) {
|
async function getUserIdsForGroup(schema, userGroupId) {
|
||||||
return db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(userGroupId).map(r => r.user_id);
|
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) ────────────────────────
|
// GET /me — current user's user-group memberships
|
||||||
router.get('/me', authMiddleware, (req, res) => {
|
router.get('/me', authMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
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);
|
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.user.id]);
|
||||||
res.json({ groupIds });
|
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(',');
|
||||||
// ── MULTI-GROUP DMs — must come before /:id ───────────────────────────────────
|
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
|
||||||
router.get('/multigroup', authMiddleware, teamManagerMiddleware, (req, res) => {
|
const mgDms = await query(req.schema, `
|
||||||
const db = getDb();
|
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
|
||||||
const dms = db.prepare(`
|
FROM multi_group_dms mgd
|
||||||
SELECT mgd.*,
|
JOIN multi_group_dm_members mgdm ON mgdm.multi_group_dm_id=mgd.id
|
||||||
(SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id = mgd.id) as group_count
|
WHERE mgdm.user_group_id IN (${placeholders})
|
||||||
FROM multi_group_dms mgd ORDER BY mgd.name ASC
|
GROUP BY mgd.id ORDER BY mgd.name ASC
|
||||||
`).all();
|
`, groupIds);
|
||||||
for (const dm of dms) {
|
for (const dm of mgDms) {
|
||||||
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);
|
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
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.` });
|
|
||||||
}
|
}
|
||||||
}
|
res.json({ userGroups, multiGroupDms: mgDms });
|
||||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
// GET /multigroup
|
||||||
const db = getDb();
|
router.get('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
|
try {
|
||||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
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;
|
const { name, userGroupIds } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
if (name && name.trim() !== mg.name) {
|
if (!Array.isArray(userGroupIds) || userGroupIds.length < 2) return res.status(400).json({ error: 'At least 2 groups required' });
|
||||||
if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), mg.id)) {
|
try {
|
||||||
return res.status(400).json({ error: 'Name already in use' });
|
// 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);
|
// Create the chat group
|
||||||
if (mg.dm_group_id) db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.dm_group_id);
|
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) {
|
// PATCH /multigroup/:id
|
||||||
const newGroupIds = new Set(userGroupIds.map(Number).filter(Boolean));
|
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
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));
|
const { userGroupIds } = req.body;
|
||||||
|
try {
|
||||||
for (const ugId of newGroupIds) {
|
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)) {
|
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);
|
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]);
|
||||||
// Add users silently — no per-user notifications in multi-group DMs
|
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||||
for (const uid of getUserIdsForGroup(db, ugId)) addUserSilent(db, mg.dm_group_id, uid);
|
for (const uid of uids) await addUserSilent(req.schema, mg.dm_group_id, uid);
|
||||||
const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId);
|
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A new group has joined this conversation.`);
|
||||||
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been added to this conversation.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const ugId of currentGroupIds) {
|
for (const ugId of currentGroupIds) {
|
||||||
if (!newGroupIds.has(ugId)) {
|
if (!newGroupSet.has(ugId)) {
|
||||||
db.prepare('DELETE FROM multi_group_dm_members WHERE multi_group_dm_id = ? AND user_group_id = ?').run(mg.id, 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]);
|
||||||
// Remove users silently — no per-user notifications in multi-group DMs
|
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||||
for (const uid of getUserIdsForGroup(db, ugId)) {
|
for (const uid of uids) {
|
||||||
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);
|
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) {
|
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.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
|
||||||
io.to(`user:${uid}`).emit('group:deleted', { groupId: 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);
|
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
|
||||||
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been removed from this conversation.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
// DELETE /multigroup/:id
|
||||||
const db = getDb();
|
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
|
try {
|
||||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
|
||||||
if (mg.dm_group_id) {
|
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(mg.dm_group_id).map(r => r.user_id);
|
if (mg.dm_group_id) {
|
||||||
db.prepare('DELETE FROM groups WHERE id = ?').run(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);
|
||||||
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_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 });
|
||||||
db.prepare('DELETE FROM multi_group_dms WHERE id = ?').run(mg.id);
|
}
|
||||||
res.json({ success: true });
|
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 ───────────────────────────────────────────────────────────────
|
// GET / — list all user groups
|
||||||
|
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
try {
|
||||||
const db = getDb();
|
const groups = await query(req.schema, `
|
||||||
const groups = db.prepare(`
|
SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id=ug.id) AS member_count
|
||||||
SELECT ug.*,
|
FROM user_groups ug ORDER BY ug.name ASC
|
||||||
(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 });
|
||||||
`).all();
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json({ groups });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
// GET /:id
|
||||||
const db = getDb();
|
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
try {
|
||||||
if (!group) return res.status(404).json({ error: 'Not found' });
|
const group = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||||
const members = db.prepare(`
|
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
|
const members = await query(req.schema, `
|
||||||
FROM user_group_members ugm JOIN users u ON u.id = ugm.user_id
|
SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status
|
||||||
WHERE ugm.user_group_id = ? ORDER BY u.name ASC
|
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
||||||
`).all(req.params.id);
|
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
||||||
res.json({ group, members });
|
`, [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;
|
const { name, memberIds = [] } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
const db = getDb();
|
try {
|
||||||
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
|
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||||
return res.status(400).json({ error: 'A group with that name already exists' });
|
if (existing) return res.status(400).json({ error: 'Name already in use' });
|
||||||
}
|
// Create the managed DM group
|
||||||
// Check for duplicate member set
|
const gr = await queryResult(req.schema,
|
||||||
const newIds = [...new Set((Array.isArray(memberIds) ? memberIds : []).map(Number).filter(Boolean))].sort();
|
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||||
if (newIds.length > 0) {
|
[name.trim()]
|
||||||
const allGroups = db.prepare('SELECT id, name FROM user_groups').all();
|
);
|
||||||
for (const existing of allGroups) {
|
const dmGroupId = gr.rows[0].id;
|
||||||
const existingIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(existing.id).map(r => r.user_id).sort();
|
const ugr = await queryResult(req.schema,
|
||||||
if (existingIds.length === newIds.length && existingIds.every((id, i) => id === newIds[i])) {
|
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||||
return res.status(400).json({ error: `Group not created — "${existing.name}" already exists with the same members.` });
|
[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 ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
|
||||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
res.json({ userGroup: ug });
|
||||||
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);
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
// PATCH /:id
|
||||||
const db = getDb();
|
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
|
||||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
|
||||||
const { name, memberIds } = req.body;
|
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 (name && name.trim() !== ug.name) {
|
||||||
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id)) {
|
const conflict = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), ug.id]);
|
||||||
return res.status(400).json({ error: 'Name already in use' });
|
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]);
|
||||||
db.prepare("UPDATE user_groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(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]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For multi-group DMs: add/remove users silently, post group-level notification once
|
if (Array.isArray(memberIds) && ug.dm_group_id) {
|
||||||
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);
|
const newIds = new Set(memberIds.map(Number).filter(Boolean));
|
||||||
for (const mg of mgDms) {
|
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));
|
||||||
if (!mg.dm_group_id) continue;
|
const addedUids = [], removedUids = [];
|
||||||
for (const uid of addedUids) addUserSilent(db, mg.dm_group_id, uid);
|
|
||||||
for (const uid of removedUids) {
|
for (const uid of newIds) {
|
||||||
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 (!currentSet.has(uid)) {
|
||||||
if (!stillInMg) {
|
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
|
await addUser(req.schema, ug.dm_group_id, uid, req.user.id);
|
||||||
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
|
addedUids.push(uid);
|
||||||
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
|
}
|
||||||
|
}
|
||||||
|
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);
|
// Propagate to multi-group DMs
|
||||||
res.json({ group: updated });
|
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) => {
|
// DELETE /:id
|
||||||
const db = getDb();
|
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
try {
|
||||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||||
if (ug.dm_group_id) {
|
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(ug.dm_group_id).map(r => r.user_id);
|
if (ug.dm_group_id) {
|
||||||
db.prepare('DELETE FROM groups WHERE id = ?').run(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);
|
||||||
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_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 }); }
|
||||||
db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id);
|
}
|
||||||
res.json({ success: true });
|
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;
|
return router;
|
||||||
|
|||||||
@@ -1,318 +1,264 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
|
const { query, queryOne, queryResult, exec, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
const avatarStorage = multer.diskStorage({
|
const avatarStorage = multer.diskStorage({
|
||||||
destination: '/app/uploads/avatars',
|
destination: '/app/uploads/avatars',
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => cb(null, `avatar_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`),
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const uploadAvatar = multer({
|
const uploadAvatar = multer({
|
||||||
storage: avatarStorage,
|
storage: avatarStorage,
|
||||||
limits: { fileSize: 2 * 1024 * 1024 },
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
|
||||||
else cb(new Error('Images only'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
|
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||||
function resolveUniqueName(db, baseName, excludeId = null) {
|
const existing = await query(schema,
|
||||||
const existing = db.prepare(
|
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||||
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
|
[excludeId ?? -1, baseName, `${baseName} (%)`]
|
||||||
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
|
);
|
||||||
if (existing.length === 0) return baseName;
|
if (existing.length === 0) return baseName;
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const u of existing) {
|
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); }
|
||||||
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})`;
|
return `${baseName} (${max + 1})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidEmail(email) {
|
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultPassword(db) {
|
// List users
|
||||||
return process.env.USER_PASS || 'user@1234';
|
router.get('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
}
|
try {
|
||||||
|
const users = await query(req.schema,
|
||||||
// List users (admin)
|
"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"
|
||||||
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
);
|
||||||
const db = getDb();
|
res.json({ users });
|
||||||
const users = db.prepare(`
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search users (public-ish for mentions/add-member)
|
// Search users
|
||||||
router.get('/search', authMiddleware, (req, res) => {
|
router.get('/search', authMiddleware, async (req, res) => {
|
||||||
const { q, groupId } = req.query;
|
const { q, groupId } = req.query;
|
||||||
const db = getDb();
|
try {
|
||||||
let users;
|
let users;
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const group = db.prepare('SELECT type, is_direct FROM groups WHERE id = ?').get(parseInt(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)) {
|
if (group && (group.type === 'private' || group.is_direct)) {
|
||||||
// Private group or direct message — only show members of this group
|
users = await query(req.schema,
|
||||||
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=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
|
||||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
|
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||||
FROM users u
|
);
|
||||||
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
|
} else {
|
||||||
WHERE u.status = 'active' AND u.id != ?
|
users = await query(req.schema,
|
||||||
AND (u.name LIKE ? OR u.display_name LIKE ?)
|
"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",
|
||||||
LIMIT 10
|
[req.user.id, `%${q}%`]
|
||||||
`).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`);
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Public group — all active users
|
users = await query(req.schema,
|
||||||
users = db.prepare(`
|
"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",
|
||||||
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
|
[`%${q}%`]
|
||||||
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
|
);
|
||||||
LIMIT 10
|
|
||||||
`).all(req.user.id, `%${q}%`, `%${q}%`);
|
|
||||||
}
|
}
|
||||||
} else {
|
res.json({ users });
|
||||||
users = db.prepare(`
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if a display name is already taken (excludes self)
|
// Check display name
|
||||||
router.get('/check-display-name', authMiddleware, (req, res) => {
|
router.get('/check-display-name', authMiddleware, async (req, res) => {
|
||||||
const { name } = req.query;
|
const { name } = req.query;
|
||||||
if (!name) return res.json({ taken: false });
|
if (!name) return res.json({ taken: false });
|
||||||
const db = getDb();
|
try {
|
||||||
const conflict = db.prepare(
|
const conflict = await queryOne(req.schema,
|
||||||
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||||
).get(name, req.user.id);
|
[name, req.user.id]
|
||||||
res.json({ taken: !!conflict });
|
);
|
||||||
|
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
|
// Create user
|
||||||
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { name, email, password, role } = req.body;
|
const { name, email, password, role } = req.body;
|
||||||
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
|
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' });
|
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
|
||||||
|
try {
|
||||||
const db = getDb();
|
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email = $1', [email]);
|
||||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||||
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 resolvedName = resolveUniqueName(db, name.trim());
|
const hash = bcrypt.hashSync(pw, 10);
|
||||||
const pw = (password || '').trim() || getDefaultPassword(db);
|
const r = await queryResult(req.schema,
|
||||||
const hash = bcrypt.hashSync(pw, 10);
|
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||||
const result = db.prepare(`
|
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
|
||||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
);
|
||||||
VALUES (?, ?, ?, ?, 'active', 1)
|
const userId = r.rows[0].id;
|
||||||
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
|
await addUserToPublicGroups(req.schema, userId);
|
||||||
|
if (role === 'admin') {
|
||||||
addUserToPublicGroups(result.lastInsertRowid);
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
// Admin users are automatically added to the Support group
|
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [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 });
|
||||||
res.json({ user });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk create users
|
// Bulk create
|
||||||
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { users } = req.body;
|
const { users } = req.body;
|
||||||
const db = getDb();
|
|
||||||
const results = { created: [], skipped: [] };
|
const results = { created: [], skipped: [] };
|
||||||
const seenEmails = new Set();
|
const seenEmails = new Set();
|
||||||
const defaultPw = getDefaultPassword(db);
|
const defaultPw = process.env.USER_PASS || 'user@1234';
|
||||||
|
try {
|
||||||
const insertUser = db.prepare(`
|
for (const u of users) {
|
||||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
const email = (u.email || '').trim().toLowerCase();
|
||||||
VALUES (?, ?, ?, ?, 'active', 1)
|
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; }
|
||||||
for (const u of users) {
|
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||||
const email = (u.email || '').trim().toLowerCase();
|
seenEmails.add(email);
|
||||||
const name = (u.name || '').trim();
|
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email=$1', [email]);
|
||||||
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
|
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
||||||
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
try {
|
||||||
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||||
seenEmails.add(email);
|
const pw = (u.password || '').trim() || defaultPw;
|
||||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
const hash = bcrypt.hashSync(pw, 10);
|
||||||
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
const newRole = u.role === 'admin' ? 'admin' : 'member';
|
||||||
try {
|
const r = await queryResult(req.schema,
|
||||||
const resolvedName = resolveUniqueName(db, name);
|
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||||
const pw = (u.password || '').trim() || defaultPw;
|
[resolvedName, email, hash, newRole]
|
||||||
const hash = bcrypt.hashSync(pw, 10);
|
);
|
||||||
const newRole = u.role === 'admin' ? 'admin' : 'member';
|
await addUserToPublicGroups(req.schema, r.rows[0].id);
|
||||||
const r = insertUser.run(resolvedName, email, hash, newRole);
|
if (newRole === 'admin') {
|
||||||
addUserToPublicGroups(r.lastInsertRowid);
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
if (newRole === 'admin') {
|
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]);
|
||||||
const supportGroupId = getOrCreateSupportGroup();
|
|
||||||
if (supportGroupId) {
|
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
|
|
||||||
}
|
}
|
||||||
}
|
results.created.push(email);
|
||||||
results.created.push(email);
|
} catch (e) { results.skipped.push({ email, reason: e.message }); }
|
||||||
} catch (e) {
|
|
||||||
results.skipped.push({ email, reason: e.message });
|
|
||||||
}
|
}
|
||||||
}
|
res.json(results);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
res.json(results);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user name (admin only — req 5)
|
// Patch name
|
||||||
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
const db = getDb();
|
try {
|
||||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
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) 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 = await resolveUniqueName(req.schema, name.trim(), req.params.id);
|
||||||
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
|
await exec(req.schema, 'UPDATE users SET name=$1, updated_at=NOW() WHERE id=$2', [resolvedName, target.id]);
|
||||||
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
|
res.json({ success: true, name: resolvedName });
|
||||||
res.json({ success: true, name: resolvedName });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user role (admin)
|
// Patch role
|
||||||
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { role } = req.body;
|
const { role } = req.body;
|
||||||
const db = getDb();
|
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
try {
|
||||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
||||||
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
|
await exec(req.schema, 'UPDATE users SET role=$1, updated_at=NOW() WHERE id=$2', [role, target.id]);
|
||||||
// If promoted to admin, ensure they're in the Support group
|
if (role === 'admin') {
|
||||||
if (role === 'admin') {
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
const supportGroupId = getOrCreateSupportGroup();
|
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, target.id]);
|
||||||
if (supportGroupId) {
|
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
|
|
||||||
}
|
}
|
||||||
}
|
res.json({ success: true });
|
||||||
res.json({ success: true });
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset user password (admin)
|
// Reset password
|
||||||
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
|
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
|
||||||
const db = getDb();
|
try {
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
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);
|
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 });
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suspend user (admin)
|
// Suspend / activate / delete
|
||||||
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const db = getDb();
|
try {
|
||||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
const t = 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 (!t) return res.status(404).json({ error: 'User not found' });
|
||||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
if (t.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);
|
await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]);
|
||||||
res.json({ success: true });
|
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)
|
// Update own profile
|
||||||
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/me/profile', authMiddleware, async (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) => {
|
|
||||||
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
||||||
const db = getDb();
|
try {
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
const conflict = db.prepare(
|
const conflict = await queryOne(req.schema,
|
||||||
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||||
).get(displayName, req.user.id);
|
[displayName, req.user.id]
|
||||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
);
|
||||||
}
|
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);
|
await exec(req.schema,
|
||||||
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);
|
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
|
||||||
res.json({ user });
|
[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) => {
|
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
try {
|
try {
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const filePath = req.file.path;
|
const filePath = req.file.path;
|
||||||
const fileSizeBytes = req.file.size;
|
const MAX_DIM = 256;
|
||||||
const FIVE_HUNDRED_KB = 500 * 1024;
|
const image = sharp(filePath);
|
||||||
const MAX_DIM = 256; // max width/height in pixels
|
const meta = await image.metadata();
|
||||||
|
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
|
||||||
const image = sharp(filePath);
|
if (req.file.size >= 500 * 1024 || needsResize) {
|
||||||
const meta = await image.metadata();
|
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||||
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
|
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
|
||||||
|
const fs = require('fs');
|
||||||
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
|
fs.unlinkSync(filePath);
|
||||||
// Small enough and already correctly sized — serve as-is
|
const avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||||
} else {
|
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||||
// Resize (and compress only if over 500 KB)
|
return res.json({ avatarUrl });
|
||||||
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 avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
const db = getDb();
|
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
|
||||||
res.json({ avatarUrl });
|
res.json({ avatarUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Avatar processing error:', err);
|
console.error('Avatar error:', err);
|
||||||
// Fall back to serving unprocessed file
|
|
||||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
const db = getDb();
|
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]).catch(()=>{});
|
||||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
|
||||||
res.json({ avatarUrl });
|
res.json({ avatarUrl });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.9.87}"
|
VERSION="${1:-0.10.1}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
97
docker-compose.host.yaml
Normal file
97
docker-compose.host.yaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# docker-compose.host.yaml — JAMA-HOST multi-tenant deployment
|
||||||
|
#
|
||||||
|
# Use this instead of docker-compose.yaml when running JAMA-HOST.
|
||||||
|
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.host.yaml up -d
|
||||||
|
#
|
||||||
|
# Required .env additions for host mode:
|
||||||
|
# APP_TYPE=host
|
||||||
|
# HOST_DOMAIN=jamachat.com
|
||||||
|
# HOST_ADMIN_KEY=your_secret_host_admin_key
|
||||||
|
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
|
||||||
|
|
||||||
|
services:
|
||||||
|
jama:
|
||||||
|
image: jama:${JAMA_VERSION:-latest}
|
||||||
|
container_name: ${PROJECT_NAME:-jama}
|
||||||
|
restart: unless-stopped
|
||||||
|
# No direct port exposure — traffic comes through Caddy
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
- APP_TYPE=host
|
||||||
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
||||||
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
|
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
|
- APP_NAME=${APP_NAME:-jama}
|
||||||
|
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME:-jama}
|
||||||
|
- DB_USER=${DB_USER:-jama}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
|
||||||
|
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
|
||||||
|
volumes:
|
||||||
|
- jama_uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: ${PROJECT_NAME:-jama}_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME:-jama}
|
||||||
|
- POSTGRES_USER=${DB_USER:-jama}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
volumes:
|
||||||
|
- jama_db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
# Use a Caddy build with your DNS provider plugin.
|
||||||
|
# Pre-built images: https://github.com/abiosoft/caddy-docker
|
||||||
|
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: ${PROJECT_NAME:-jama}_caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3
|
||||||
|
environment:
|
||||||
|
- CF_API_TOKEN=${CF_API_TOKEN:-} # DNS provider token for wildcard certs
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile.example:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
- /var/log/caddy:/var/log/caddy
|
||||||
|
depends_on:
|
||||||
|
- jama
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jama_db:
|
||||||
|
driver: local
|
||||||
|
jama_uploads:
|
||||||
|
driver: local
|
||||||
|
caddy_data:
|
||||||
|
driver: local
|
||||||
|
caddy_config:
|
||||||
|
driver: local
|
||||||
@@ -8,24 +8,48 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
|
- APP_TYPE=${APP_TYPE:-selfhost}
|
||||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
||||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
- USER_PASS=${USER_PASS:-user@1234}
|
|
||||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||||
- DB_KEY=${DB_KEY}
|
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
- APP_NAME=${APP_NAME:-jama}
|
||||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
- 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:
|
volumes:
|
||||||
- jama_db:/app/data
|
|
||||||
- jama_uploads:/app/uploads
|
- jama_uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
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:
|
volumes:
|
||||||
jama_db:
|
jama_db:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.9.87",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ToastProvider } from './contexts/ToastContext.jsx';
|
|||||||
import Login from './pages/Login.jsx';
|
import Login from './pages/Login.jsx';
|
||||||
import Chat from './pages/Chat.jsx';
|
import Chat from './pages/Chat.jsx';
|
||||||
import ChangePassword from './pages/ChangePassword.jsx';
|
import ChangePassword from './pages/ChangePassword.jsx';
|
||||||
|
import HostAdmin from './pages/HostAdmin.jsx';
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading, mustChangePassword } = useAuth();
|
const { user, loading, mustChangePassword } = useAuth();
|
||||||
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }) {
|
|||||||
|
|
||||||
function AuthRoute({ children }) {
|
function AuthRoute({ children }) {
|
||||||
const { user, loading, mustChangePassword } = useAuth();
|
const { user, loading, mustChangePassword } = useAuth();
|
||||||
// Always show login in light mode regardless of user's saved theme preference
|
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
||||||
@@ -28,7 +28,6 @@ function AuthRoute({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RestoreTheme() {
|
function RestoreTheme() {
|
||||||
// Called when entering a protected route — restore the user's saved theme
|
|
||||||
const saved = localStorage.getItem('jama-theme') || 'light';
|
const saved = localStorage.getItem('jama-theme') || 'light';
|
||||||
document.documentElement.setAttribute('data-theme', saved);
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
return null;
|
return null;
|
||||||
@@ -38,16 +37,24 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<Routes>
|
||||||
<SocketProvider>
|
{/* /host renders outside AuthProvider — has its own key-based auth */}
|
||||||
<Routes>
|
<Route path="/host" element={<HostAdmin />} />
|
||||||
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
|
<Route path="/host/*" element={<HostAdmin />} />
|
||||||
<Route path="/change-password" element={<ChangePassword />} />
|
{/* All other routes go through jama auth */}
|
||||||
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
|
<Route path="/*" element={
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<AuthProvider>
|
||||||
</Routes>
|
<SocketProvider>
|
||||||
</SocketProvider>
|
<Routes>
|
||||||
</AuthProvider>
|
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
|
||||||
|
<Route path="/change-password" element={<ChangePassword />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</SocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
|
|||||||
|
|
||||||
{/* User section */}
|
{/* User section */}
|
||||||
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
|
{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 */}
|
{/* Admin section */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|||||||
@@ -221,8 +221,13 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const [userPass, setUserPass] = useState('user@1234');
|
const [userPass, setUserPass] = useState('user@1234');
|
||||||
|
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
const load = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -305,6 +310,11 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
|
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center', color: 'var(--error)' }}>
|
||||||
|
<div style={{ marginBottom: 10 }}>⚠ {loadError}</div>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setLoading(true); load(); }}>Retry</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
{filtered.map(u => (
|
{filtered.map(u => (
|
||||||
|
|||||||
602
frontend/src/pages/HostAdmin.jsx
Normal file
602
frontend/src/pages/HostAdmin.jsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
|
||||||
|
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
|
||||||
|
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLAN_BADGE = {
|
||||||
|
chat: { bg: '#e8f0fe', color: '#1a73e8', label: 'Chat' },
|
||||||
|
brand: { bg: '#fce8b2', color: '#e37400', label: 'Brand' },
|
||||||
|
team: { bg: '#e6f4ea', color: '#188038', label: 'Team' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
active: { bg: '#e6f4ea', color: '#188038' },
|
||||||
|
suspended: { bg: '#fce8b2', color: '#e37400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useHostApi(adminKey) {
|
||||||
|
const call = useCallback(async (method, path, body) => {
|
||||||
|
const res = await fetch(`/api/host${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Host-Admin-Key': adminKey,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}, [adminKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getStatus: () => call('GET', '/status'),
|
||||||
|
getTenants: () => call('GET', '/tenants'),
|
||||||
|
createTenant: (body) => call('POST', '/tenants', body),
|
||||||
|
updateTenant: (slug, b) => call('PATCH', `/tenants/${slug}`, b),
|
||||||
|
deleteTenant: (slug) => call('DELETE', `/tenants/${slug}`, { confirm: `DELETE ${slug}` }),
|
||||||
|
suspendTenant:(slug) => call('PATCH', `/tenants/${slug}`, { status: 'suspended' }),
|
||||||
|
activateTenant:(slug) => call('PATCH', `/tenants/${slug}`, { status: 'active' }),
|
||||||
|
migrateAll: () => call('POST', '/migrate-all'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small reusable components ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Badge({ value, map }) {
|
||||||
|
const s = map[value] || { bg: '#f1f3f4', color: '#5f6368' };
|
||||||
|
return (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||||
|
background: s.bg, color: s.color, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
|
||||||
|
{s.label || value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Btn({ onClick, children, variant = 'secondary', size = 'md', disabled, style = {} }) {
|
||||||
|
const base = {
|
||||||
|
border: 'none', borderRadius: 6, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
opacity: disabled ? 0.5 : 1, transition: 'opacity 0.15s',
|
||||||
|
padding: size === 'sm' ? '5px 12px' : '9px 18px',
|
||||||
|
fontSize: size === 'sm' ? 12 : 14,
|
||||||
|
};
|
||||||
|
const variants = {
|
||||||
|
primary: { background: '#1a73e8', color: '#fff' },
|
||||||
|
danger: { background: '#d93025', color: '#fff' },
|
||||||
|
warning: { background: '#e37400', color: '#fff' },
|
||||||
|
success: { background: '#188038', color: '#fff' },
|
||||||
|
secondary:{ background: '#f1f3f4', color: '#202124' },
|
||||||
|
ghost: { background: 'transparent', color: '#5f6368', padding: size === 'sm' ? '4px 8px' : '8px 12px' },
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled} style={{ ...base, ...variants[variant], ...style }}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ label, value, onChange, placeholder, type = 'text', required, hint, autoComplete }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{label && (
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#5f6368' }}>
|
||||||
|
{label}{required && <span style={{ color: '#d93025', marginLeft: 2 }}>*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type} value={value} onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder} required={required}
|
||||||
|
autoComplete={autoComplete || 'new-password'} autoCorrect="off" spellCheck={false}
|
||||||
|
style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
|
||||||
|
fontSize: 14, outline: 'none', background: '#fff', color: '#202124',
|
||||||
|
transition: 'border-color 0.15s' }}
|
||||||
|
onFocus={e => e.target.style.borderColor = '#1a73e8'}
|
||||||
|
onBlur={e => e.target.style.borderColor = '#e0e0e0'}
|
||||||
|
/>
|
||||||
|
{hint && <span style={{ fontSize: 11, color: '#9aa0a6' }}>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ label, value, onChange, options, required }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{label && <label style={{ fontSize: 12, fontWeight: 600, color: '#5f6368' }}>{label}{required && <span style={{ color: '#d93025', marginLeft: 2 }}>*</span>}</label>}
|
||||||
|
<select value={value} onChange={e => onChange(e.target.value)}
|
||||||
|
style={{ padding: '8px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
|
||||||
|
fontSize: 14, outline: 'none', background: '#fff', color: '#202124' }}>
|
||||||
|
{options.map(o => <option key={o.value} value={o.value}>{o.label}{o.desc ? ` — ${o.desc}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Modal({ title, onClose, children, width = 480 }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 12, width: '100%', maxWidth: width,
|
||||||
|
maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '18px 24px', borderBottom: '1px solid #e0e0e0' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 16 }}>{title}</span>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 20, color: '#9aa0a6', lineHeight: 1, padding: 4 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 24 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({ toasts }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', bottom: 24, right: 24, display: 'flex', flexDirection: 'column',
|
||||||
|
gap: 8, zIndex: 2000 }}>
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} style={{ padding: '12px 18px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||||
|
background: t.type === 'error' ? '#d93025' : t.type === 'warning' ? '#e37400' : '#188038',
|
||||||
|
color: '#fff', boxShadow: '0 2px 8px rgba(0,0,0,0.2)', maxWidth: 360 }}>
|
||||||
|
{t.msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provision tenant modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProvisionModal({ api, baseDomain, onClose, onDone }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
slug: '', name: '', plan: 'chat',
|
||||||
|
adminEmail: '', adminName: 'Admin User', adminPass: '',
|
||||||
|
customDomain: '',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
if (!form.slug || !form.name) return setError('Slug and name are required');
|
||||||
|
setSaving(true); setError('');
|
||||||
|
try {
|
||||||
|
const { tenant } = await api.createTenant({
|
||||||
|
slug: form.slug.toLowerCase().trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
plan: form.plan,
|
||||||
|
adminEmail: form.adminEmail || undefined,
|
||||||
|
adminName: form.adminName || undefined,
|
||||||
|
adminPass: form.adminPass || undefined,
|
||||||
|
customDomain: form.customDomain || undefined,
|
||||||
|
});
|
||||||
|
onDone(tenant);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = form.slug ? `${form.slug.toLowerCase()}.${baseDomain}` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Provision New Tenant" onClose={onClose} width={520}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{error && <div style={{ padding: '10px 14px', background: '#fce8e6', color: '#d93025',
|
||||||
|
borderRadius: 6, fontSize: 13 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<Input label="Slug" value={form.slug} onChange={set('slug')} required
|
||||||
|
placeholder="team-alpha"
|
||||||
|
hint={preview ? `URL: ${preview}` : 'Used as subdomain + schema name'} />
|
||||||
|
<Input label="Display Name" value={form.name} onChange={set('name')} required placeholder="Team Alpha" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} required />
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid #e0e0e0', paddingTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9aa0a6', textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px', marginBottom: 12 }}>First Admin User (optional — defaults to .env values)</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<Input label="Admin Email" value={form.adminEmail} onChange={set('adminEmail')}
|
||||||
|
placeholder="admin@teamalpha.com" type="email" />
|
||||||
|
<Input label="Admin Name" value={form.adminName} onChange={set('adminName')}
|
||||||
|
placeholder="Admin User" />
|
||||||
|
<Input label="Temp Password" value={form.adminPass} onChange={set('adminPass')}
|
||||||
|
placeholder="Auto-generated if blank" type="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid #e0e0e0', paddingTop: 12 }}>
|
||||||
|
<Input label="Custom Domain (optional)" value={form.customDomain} onChange={set('customDomain')}
|
||||||
|
placeholder="chat.teamalpha.com"
|
||||||
|
hint="Tenant can also be reached at this domain once DNS is configured" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4 }}>
|
||||||
|
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
|
||||||
|
<Btn onClick={handle} variant="primary" disabled={saving}>
|
||||||
|
{saving ? 'Provisioning…' : '✦ Provision Tenant'}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit tenant modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EditModal({ api, tenant, onClose, onDone }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
setSaving(true); setError('');
|
||||||
|
try {
|
||||||
|
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
||||||
|
name: form.name || undefined,
|
||||||
|
plan: form.plan,
|
||||||
|
customDomain: form.customDomain || null,
|
||||||
|
});
|
||||||
|
onDone(updated);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Edit — ${tenant.slug}`} onClose={onClose}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{error && <div style={{ padding: '10px 14px', background: '#fce8e6', color: '#d93025',
|
||||||
|
borderRadius: 6, fontSize: 13 }}>{error}</div>}
|
||||||
|
<Input label="Display Name" value={form.name} onChange={set('name')} required />
|
||||||
|
<Select label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||||
|
<Input label="Custom Domain" value={form.customDomain} onChange={set('customDomain')}
|
||||||
|
placeholder="chat.example.com" hint="Leave blank to remove custom domain" />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
|
||||||
|
<Btn onClick={handle} variant="primary" disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DeleteModal({ api, tenant, onClose, onDone }) {
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const expected = `DELETE ${tenant.slug}`;
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
setDeleting(true); setError('');
|
||||||
|
try {
|
||||||
|
await api.deleteTenant(tenant.slug);
|
||||||
|
onDone(tenant.slug);
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setDeleting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Delete Tenant" onClose={onClose}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div style={{ padding: '12px 16px', background: '#fce8e6', borderRadius: 8, fontSize: 13, color: '#d93025' }}>
|
||||||
|
<strong>This is permanent.</strong> The tenant's Postgres schema and all data —
|
||||||
|
messages, events, users, uploads — will be deleted and cannot be recovered.
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: '#202124' }}>
|
||||||
|
To confirm, type <code style={{ background: '#f1f3f4', padding: '2px 6px',
|
||||||
|
borderRadius: 4, fontFamily: 'monospace' }}>{expected}</code> below:
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ color: '#d93025', fontSize: 13 }}>{error}</div>}
|
||||||
|
<Input value={confirm} onChange={setConfirm} placeholder={expected} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Btn onClick={onClose} variant="secondary">Cancel</Btn>
|
||||||
|
<Btn onClick={handle} variant="danger" disabled={confirm !== expected || deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Permanently Delete'}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tenant row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TenantRow({ tenant, baseDomain, api, onRefresh, onToast }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const subdomainUrl = `https://${tenant.slug}.${baseDomain}`;
|
||||||
|
const url = tenant.custom_domain ? `https://${tenant.custom_domain}` : subdomainUrl;
|
||||||
|
|
||||||
|
const toggleStatus = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (tenant.status === 'active') await api.suspendTenant(tenant.slug);
|
||||||
|
else await api.activateTenant(tenant.slug);
|
||||||
|
onRefresh();
|
||||||
|
onToast(`Tenant ${tenant.slug} ${tenant.status === 'active' ? 'suspended' : 'activated'}`, 'success');
|
||||||
|
} catch (e) { onToast(e.message, 'error'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr style={{ borderBottom: '1px solid #e0e0e0' }}>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{tenant.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9aa0a6', fontFamily: 'monospace' }}>{tenant.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<Badge value={tenant.plan} map={PLAN_BADGE} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<Badge value={tenant.status} map={STATUS_BADGE} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer"
|
||||||
|
style={{ fontSize: 12, color: '#1a73e8', textDecoration: 'none' }}>
|
||||||
|
{url} ↗
|
||||||
|
</a>
|
||||||
|
{tenant.custom_domain && (
|
||||||
|
<div style={{ fontSize: 11, color: '#9aa0a6' }}>{subdomainUrl}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 12, color: '#9aa0a6', whiteSpace: 'nowrap' }}>
|
||||||
|
{new Date(tenant.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<Btn size="sm" variant="ghost" onClick={() => setEditing(true)}>Edit</Btn>
|
||||||
|
<Btn size="sm" variant={tenant.status === 'active' ? 'warning' : 'success'}
|
||||||
|
onClick={toggleStatus} disabled={busy}>
|
||||||
|
{busy ? '…' : tenant.status === 'active' ? 'Suspend' : 'Activate'}
|
||||||
|
</Btn>
|
||||||
|
<Btn size="sm" variant="danger" onClick={() => setDeleting(true)}>Delete</Btn>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditModal api={api} tenant={tenant} onClose={() => setEditing(false)}
|
||||||
|
onDone={() => { setEditing(false); onRefresh(); onToast('Tenant updated', 'success'); }} />
|
||||||
|
)}
|
||||||
|
{deleting && (
|
||||||
|
<DeleteModal api={api} tenant={tenant} onClose={() => setDeleting(false)}
|
||||||
|
onDone={() => { setDeleting(false); onRefresh(); onToast('Tenant deleted', 'success'); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key entry screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KeyEntry({ onSubmit }) {
|
||||||
|
const [key, setKey] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
if (!key.trim()) return setError('Admin key required');
|
||||||
|
setError('');
|
||||||
|
const res = await fetch('/api/host/status', {
|
||||||
|
headers: { 'X-Host-Admin-Key': key.trim() },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
sessionStorage.setItem('jama-host-key', key.trim());
|
||||||
|
onSubmit(key.trim());
|
||||||
|
} else {
|
||||||
|
setError('Invalid admin key');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: '#f1f3f4' }}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
|
||||||
|
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>JAMA-HOST</h1>
|
||||||
|
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
|
||||||
|
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
|
||||||
|
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
|
||||||
|
<input
|
||||||
|
type="password" value={key} onChange={e => setKey(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handle()}
|
||||||
|
placeholder="Host admin key" autoFocus
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #e0e0e0', borderRadius: 6,
|
||||||
|
fontSize: 14, outline: 'none', boxSizing: 'border-box', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Btn onClick={handle} variant="primary" style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
Sign In
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main host admin panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function HostAdmin() {
|
||||||
|
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [tenants, setTenants] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [provisioning, setProvisioning] = useState(false);
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const api = useHostApi(adminKey);
|
||||||
|
|
||||||
|
const toast = useCallback((msg, type = 'success') => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts(t => [...t, { id, msg, type }]);
|
||||||
|
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [s, t] = await Promise.all([api.getStatus(), api.getTenants()]);
|
||||||
|
setStatus(s);
|
||||||
|
setTenants(t.tenants);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
if (e.message.includes('Invalid') || e.message.includes('401')) {
|
||||||
|
sessionStorage.removeItem('jama-host-key');
|
||||||
|
setAdminKey('');
|
||||||
|
}
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [api, toast]);
|
||||||
|
|
||||||
|
useEffect(() => { if (adminKey) load(); }, [adminKey]);
|
||||||
|
|
||||||
|
const handleMigrateAll = async () => {
|
||||||
|
setMigrating(true);
|
||||||
|
try {
|
||||||
|
const { results } = await api.migrateAll();
|
||||||
|
const errors = results.filter(r => r.status === 'error');
|
||||||
|
if (errors.length) toast(`${errors.length} migration(s) failed — check logs`, 'error');
|
||||||
|
else toast(`Migrations applied to ${results.length} tenant(s)`, 'success');
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
finally { setMigrating(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!adminKey) return <KeyEntry onSubmit={setAdminKey} />;
|
||||||
|
|
||||||
|
const filtered = tenants.filter(t =>
|
||||||
|
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
t.slug.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
const baseDomain = status?.baseDomain || 'jamachat.com';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background: '#1a73e8', color: '#fff', padding: '0 24px' }}>
|
||||||
|
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', height: 56 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>🏠</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 16 }}>JAMA-HOST</span>
|
||||||
|
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
{status && (
|
||||||
|
<span style={{ fontSize: 12, opacity: 0.85 }}>
|
||||||
|
{status.tenants.active} active · {status.tenants.total} total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
|
||||||
|
Sign Out
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div style={{ maxWidth: 1100, margin: '0 auto', padding: 24 }}>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
{status && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Total Tenants', value: status.tenants.total, color: '#1a73e8' },
|
||||||
|
{ label: 'Active', value: status.tenants.active, color: '#188038' },
|
||||||
|
{ label: 'Suspended', value: status.tenants.total - status.tenants.active, color: '#e37400' },
|
||||||
|
{ label: 'Mode', value: status.appType, color: '#5f6368' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ background: '#fff', borderRadius: 10, padding: '16px 20px',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: s.color }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9aa0a6', marginTop: 2 }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ background: '#fff', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||||
|
overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '16px 20px', borderBottom: '1px solid #e0e0e0', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 15 }}>Tenants</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search tenants…" autoComplete="off"
|
||||||
|
style={{ padding: '7px 10px', border: '1px solid #e0e0e0', borderRadius: 6,
|
||||||
|
fontSize: 13, outline: 'none', width: 200 }} />
|
||||||
|
<Btn size="sm" variant="secondary" onClick={load} disabled={loading}>
|
||||||
|
{loading ? '…' : '↻ Refresh'}
|
||||||
|
</Btn>
|
||||||
|
<Btn size="sm" variant="secondary" onClick={handleMigrateAll} disabled={migrating}>
|
||||||
|
{migrating ? 'Migrating…' : '⬆ Migrate All'}
|
||||||
|
</Btn>
|
||||||
|
<Btn size="sm" variant="primary" onClick={() => setProvisioning(true)}>
|
||||||
|
✦ New Tenant
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading && tenants.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#9aa0a6' }}>Loading…</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#9aa0a6' }}>
|
||||||
|
{search ? 'No tenants match your search.' : 'No tenants yet. Provision your first one!'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||||
|
{['Tenant', 'Plan', 'Status', 'URL', 'Created', 'Actions'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 16px', textAlign: h === 'Actions' ? 'right' : 'left',
|
||||||
|
fontSize: 11, fontWeight: 700, color: '#9aa0a6', textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px', whiteSpace: 'nowrap' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(t => (
|
||||||
|
<TenantRow key={t.slug} tenant={t} baseDomain={baseDomain}
|
||||||
|
api={api} onRefresh={load} onToast={toast} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
|
||||||
|
JAMA-HOST Control Plane · {baseDomain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provision modal */}
|
||||||
|
{provisioning && (
|
||||||
|
<ProvisionModal api={api} baseDomain={baseDomain} onClose={() => setProvisioning(false)}
|
||||||
|
onDone={tenant => {
|
||||||
|
setProvisioning(false);
|
||||||
|
load();
|
||||||
|
toast(`Tenant '${tenant.slug}' provisioned at https://${tenant.slug}.${baseDomain}`, 'success');
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Toast toasts={toasts} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user