v0.9.88 major change sqlite to postgres
This commit is contained in:
66
.env.example
66
.env.example
@@ -1,56 +1,30 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# jama — Configuration
|
||||
# just another messaging app
|
||||
#
|
||||
# Copy this file to .env and customize before first run.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ── Required ──────────────────────────────────────────────────────────────────
|
||||
DB_PASSWORD=change_me_strong_password
|
||||
JWT_SECRET=change_me_super_secret_jwt_key
|
||||
|
||||
# Project name — used as the Docker container name.
|
||||
# If you run multiple jama instances on the same host, give each a unique name.
|
||||
# ── App identity ──────────────────────────────────────────────────────────────
|
||||
PROJECT_NAME=jama
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
JAMA_VERSION=0.9.87
|
||||
|
||||
# App port — the host port Docker maps to the container
|
||||
PORT=3000
|
||||
|
||||
# Timezone — must match your host timezone
|
||||
# Run 'timedatectl' on Linux or 'ls /usr/share/zoneinfo' to find your value
|
||||
# Examples: America/Toronto, Europe/London, Asia/Tokyo
|
||||
TZ=UTC
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────
|
||||
# App name (can also be changed in the Settings UI after first run)
|
||||
APP_NAME=jama
|
||||
|
||||
# Default public group name (created on first run only)
|
||||
DEFCHAT_NAME=General Chat
|
||||
|
||||
# ── Admin credentials (used on FIRST RUN only) ────────────────
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_EMAIL=admin@jama.local
|
||||
ADMIN_PASS=Admin@1234
|
||||
|
||||
# Default password for bulk-imported users (when no password is set in CSV)
|
||||
USER_PASS=user@1234
|
||||
|
||||
# Set to true to reset the admin password to ADMIN_PASS on every restart.
|
||||
# WARNING: Leave false in production — shows a warning banner on the login page when true.
|
||||
ADMPW_RESET=false
|
||||
|
||||
# ── Security ──────────────────────────────────────────────────
|
||||
# JWT secret — change this to a long random string in production!
|
||||
# Generate one: openssl rand -hex 32
|
||||
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DB_NAME=jama
|
||||
DB_USER=jama
|
||||
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
||||
|
||||
# Database encryption key (SQLCipher AES-256)
|
||||
# Generate a strong key: openssl rand -hex 32
|
||||
# Leave blank to run without encryption (not recommended for production).
|
||||
#
|
||||
# IMPORTANT — upgrading an existing unencrypted install:
|
||||
# 1. docker compose down
|
||||
# 2. Find your DB: docker volume inspect <project>_jama_db
|
||||
# 3. node backend/scripts/encrypt-db.js --db /path/to/jama.db --key YOUR_KEY
|
||||
# 4. Add DB_KEY=YOUR_KEY here, then: ./build.sh && docker compose up -d
|
||||
DB_KEY=
|
||||
# ── Tenancy mode ──────────────────────────────────────────────────────────────
|
||||
# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM)
|
||||
# host = multi-tenant (JAMA-HOST only)
|
||||
APP_TYPE=selfhost
|
||||
|
||||
# ── JAMA-HOST only (ignored in selfhost mode) ─────────────────────────────────
|
||||
# HOST_DOMAIN=jamachat.com
|
||||
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
||||
|
||||
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
TZ=UTC
|
||||
|
||||
86
Caddyfile.example
Normal file
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
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install frontend dependencies and build
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm install
|
||||
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
|
||||
# Backend
|
||||
FROM node:20-alpine
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
LABEL org.opencontainers.image.title="jama" \
|
||||
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.source="https://github.com/yourorg/jama"
|
||||
org.opencontainers.image.created="${BUILD_DATE}"
|
||||
|
||||
ENV JAMA_VERSION=${VERSION}
|
||||
|
||||
RUN apk add --no-cache sqlite python3 make g++ openssl-dev
|
||||
|
||||
# No native build tools needed — pg uses pure JS by default
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after compile to keep image lean
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY backend/ ./
|
||||
COPY --from=builder /app/frontend/dist ./public
|
||||
|
||||
# Create data and uploads directories
|
||||
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.9.87",
|
||||
"version": "0.10.1",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -19,8 +19,8 @@
|
||||
"sharp": "^0.33.2",
|
||||
"socket.io": "^4.6.1",
|
||||
"web-push": "^3.6.7",
|
||||
"better-sqlite3-multiple-ciphers": "^12.6.2",
|
||||
"csv-parse": "^5.5.6"
|
||||
"csv-parse": "^5.5.6",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -5,38 +5,31 @@ const cookieParser = require('cookie-parser');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db');
|
||||
|
||||
const {
|
||||
initDb, tenantMiddleware,
|
||||
query, queryOne, queryResult, exec,
|
||||
APP_TYPE, refreshTenantCache,
|
||||
} = require('./models/db');
|
||||
|
||||
const { router: pushRouter, sendPushToUser } = require('./routes/push');
|
||||
const { getLinkPreview } = require('./utils/linkPreview');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: { origin: '*', methods: ['GET', 'POST'] }
|
||||
});
|
||||
const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } });
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Init DB
|
||||
initDb();
|
||||
seedAdmin();
|
||||
// Ensure Support group exists and all admins are members
|
||||
const supportGroupId = getOrCreateSupportGroup();
|
||||
if (supportGroupId) {
|
||||
const db = getDb();
|
||||
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const a of admins) insert.run(supportGroupId, a.id);
|
||||
}
|
||||
|
||||
// Middleware
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(tenantMiddleware);
|
||||
app.use('/uploads', express.static('/app/uploads'));
|
||||
|
||||
// API Routes
|
||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', require('./routes/auth')(io));
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/groups', require('./routes/groups')(io));
|
||||
@@ -48,7 +41,13 @@ app.use('/api/about', require('./routes/about'));
|
||||
app.use('/api/help', require('./routes/help'));
|
||||
app.use('/api/push', pushRouter);
|
||||
|
||||
// Link preview proxy
|
||||
// JAMA-HOST control plane — only registered when APP_TYPE=host
|
||||
if (APP_TYPE === 'host') {
|
||||
app.use('/api/host', require('./routes/host'));
|
||||
console.log('[Server] JAMA-HOST control plane enabled at /api/host');
|
||||
}
|
||||
|
||||
// ── Link preview proxy ────────────────────────────────────────────────────────
|
||||
app.get('/api/link-preview', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL required' });
|
||||
@@ -56,24 +55,21 @@ app.get('/api/link-preview', async (req, res) => {
|
||||
res.json({ preview });
|
||||
});
|
||||
|
||||
// Health check
|
||||
// ── Health check ──────────────────────────────────────────────────────────────
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
// Dynamic manifest — must be before express.static so it takes precedence
|
||||
app.get('/manifest.json', (req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all();
|
||||
// ── Dynamic PWA manifest ──────────────────────────────────────────────────────
|
||||
app.get('/manifest.json', async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema,
|
||||
"SELECT key, value FROM settings WHERE key IN ('app_name','logo_url','pwa_icon_192','pwa_icon_512')"
|
||||
);
|
||||
const s = {};
|
||||
for (const r of rows) s[r.key] = r.value;
|
||||
|
||||
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
||||
const pwa192 = s.pwa_icon_192 || '';
|
||||
const pwa512 = s.pwa_icon_512 || '';
|
||||
|
||||
// Use uploaded+resized icons if they exist, else fall back to bundled PNGs.
|
||||
// Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts.
|
||||
const icon192 = pwa192 || '/icons/icon-192.png';
|
||||
const icon512 = pwa512 || '/icons/icon-512.png';
|
||||
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
||||
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
|
||||
|
||||
const icons = [
|
||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
@@ -82,259 +78,273 @@ app.get('/manifest.json', (req, res) => {
|
||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.json({
|
||||
name: appName,
|
||||
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
||||
description: `${appName} - Team messaging`,
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
start_url: '/', scope: '/', display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#1a73e8',
|
||||
background_color: '#ffffff', theme_color: '#1a73e8',
|
||||
icons,
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.json(manifest);
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve frontend
|
||||
// ── Frontend ──────────────────────────────────────────────────────────────────
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// Socket.io authentication
|
||||
io.use((socket, next) => {
|
||||
// ── Socket.io authentication ──────────────────────────────────────────────────
|
||||
// Socket connections do not go through Express middleware, so we resolve
|
||||
// schema from the handshake headers manually.
|
||||
const { resolveSchema } = require('./models/db');
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
const token = socket.handshake.auth.token;
|
||||
if (!token) return next(new Error('Unauthorized'));
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
|
||||
|
||||
// Resolve tenant schema from socket handshake headers
|
||||
const schema = resolveSchema({ headers: socket.handshake.headers });
|
||||
|
||||
const user = await queryOne(schema,
|
||||
'SELECT id, name, display_name, avatar, role, status FROM users WHERE id = $1 AND status = $2',
|
||||
[decoded.id, 'active']
|
||||
);
|
||||
if (!user) return next(new Error('User not found'));
|
||||
// Per-device enforcement: token must match an active session row
|
||||
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
|
||||
|
||||
const session = await queryOne(schema,
|
||||
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2',
|
||||
[decoded.id, token]
|
||||
);
|
||||
if (!session) return next(new Error('Session displaced'));
|
||||
|
||||
socket.user = user;
|
||||
socket.token = token;
|
||||
socket.device = session.device;
|
||||
socket.schema = schema;
|
||||
next();
|
||||
} catch (e) {
|
||||
next(new Error('Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
// Track online users: userId -> Set of socketIds
|
||||
const onlineUsers = new Map();
|
||||
// ── Online user tracking ──────────────────────────────────────────────────────
|
||||
const onlineUsers = new Map(); // userId → Set<socketId>
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
io.on('connection', async (socket) => {
|
||||
const userId = socket.user.id;
|
||||
const schema = socket.schema;
|
||||
|
||||
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
||||
onlineUsers.get(userId).add(socket.id);
|
||||
|
||||
// Record last_online timestamp
|
||||
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
|
||||
// Update last_online
|
||||
exec(schema, 'UPDATE users SET last_online = NOW() WHERE id = $1', [userId]).catch(() => {});
|
||||
|
||||
// Broadcast online status
|
||||
io.emit('user:online', { userId });
|
||||
|
||||
// Join personal room for direct notifications
|
||||
socket.join(`user:${userId}`);
|
||||
|
||||
// Join rooms for all user's groups
|
||||
const db = getDb();
|
||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
||||
// Join socket rooms for all groups this user belongs to
|
||||
try {
|
||||
const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||
for (const g of publicGroups) socket.join(`group:${g.id}`);
|
||||
|
||||
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
|
||||
const privateGroups = await query(schema,
|
||||
'SELECT group_id FROM group_members WHERE user_id = $1', [userId]
|
||||
);
|
||||
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
|
||||
} catch (e) {
|
||||
console.error('[Socket] Room join error:', e.message);
|
||||
}
|
||||
|
||||
// When a new group is created and pushed to this socket, join its room
|
||||
socket.on('group:join-room', ({ groupId }) => {
|
||||
socket.join(`group:${groupId}`);
|
||||
});
|
||||
socket.on('group:join-room', ({ groupId }) => socket.join(`group:${groupId}`));
|
||||
socket.on('group:leave-room', ({ groupId }) => socket.leave(`group:${groupId}`));
|
||||
|
||||
// When a user leaves a group, remove them from the socket room
|
||||
socket.on('group:leave-room', ({ groupId }) => {
|
||||
socket.leave(`group:${groupId}`);
|
||||
});
|
||||
|
||||
// Handle new message
|
||||
// ── New message ─────────────────────────────────────────────────────────────
|
||||
socket.on('message:send', async (data) => {
|
||||
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
|
||||
const db = getDb();
|
||||
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
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;
|
||||
|
||||
// Check access
|
||||
if (group.type === 'private') {
|
||||
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
|
||||
const member = await queryOne(schema,
|
||||
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
|
||||
[groupId, userId]
|
||||
);
|
||||
if (!member) return;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
const mr = await queryResult(schema, `
|
||||
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);
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
|
||||
`, [
|
||||
groupId, userId,
|
||||
content || null,
|
||||
imageUrl || null,
|
||||
imageUrl ? 'image' : 'text',
|
||||
replyToId || null,
|
||||
linkPreview ? JSON.stringify(linkPreview) : null,
|
||||
]);
|
||||
const msgId = mr.rows[0].id;
|
||||
|
||||
const message = db.prepare(`
|
||||
const message = await queryOne(schema, `
|
||||
SELECT m.*,
|
||||
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me,
|
||||
rm.content as reply_content, rm.image_url as reply_image_url, rm.is_deleted as reply_is_deleted,
|
||||
ru.name as reply_user_name, ru.display_name as reply_user_display_name
|
||||
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);
|
||||
WHERE m.id = $1
|
||||
`, [msgId]);
|
||||
|
||||
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';
|
||||
// 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; // don't notify sender
|
||||
if (m.user_id === userId) continue;
|
||||
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,
|
||||
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);
|
||||
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process @mentions — format is @[display name], look up user by display_name or name
|
||||
// @mention notifications
|
||||
if (content) {
|
||||
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
||||
for (const mentionName of mentionNames) {
|
||||
const mentionedUser = db.prepare(
|
||||
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
|
||||
).get(mentionName, mentionName);
|
||||
const matchId = mentionedUser?.id?.toString();
|
||||
if (matchId && parseInt(matchId) !== userId) {
|
||||
const notifResult = db.prepare(`
|
||||
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
|
||||
VALUES (?, 'mention', ?, ?, ?)
|
||||
`).run(parseInt(matchId), result.lastInsertRowid, groupId, userId);
|
||||
const 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;
|
||||
|
||||
// Notify mentioned user — socket if online, push if not
|
||||
const mentionedUserId = parseInt(matchId);
|
||||
const notif = {
|
||||
id: notifResult.lastInsertRowid,
|
||||
type: 'mention',
|
||||
groupId,
|
||||
messageId: result.lastInsertRowid,
|
||||
fromUser: socket.user,
|
||||
};
|
||||
if (onlineUsers.has(mentionedUserId)) {
|
||||
for (const sid of onlineUsers.get(mentionedUserId)) {
|
||||
io.to(sid).emit('notification:new', notif);
|
||||
const 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);
|
||||
}
|
||||
}
|
||||
// Always send push (badge even when app is open)
|
||||
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
|
||||
sendPushToUser(mentionedUserId, {
|
||||
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
||||
sendPushToUser(mentioned.id, {
|
||||
title: `${senderName} mentioned you`,
|
||||
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
||||
url: '/',
|
||||
badge: 1,
|
||||
url: '/', badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Socket] message:send error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces
|
||||
socket.on('reaction:toggle', (data) => {
|
||||
const { messageId, emoji } = data;
|
||||
const db = getDb();
|
||||
const message = db.prepare('SELECT m.*, g.id as gid FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId);
|
||||
// ── Reaction toggle ─────────────────────────────────────────────────────────
|
||||
socket.on('reaction:toggle', async ({ messageId, emoji }) => {
|
||||
try {
|
||||
const message = await queryOne(schema,
|
||||
'SELECT m.*, g.id AS gid FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1 AND m.is_deleted=FALSE',
|
||||
[messageId]
|
||||
);
|
||||
if (!message) return;
|
||||
|
||||
// Find any existing reaction by this user on this message
|
||||
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId);
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2',
|
||||
[messageId, userId]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (existing.emoji === emoji) {
|
||||
// Same emoji — toggle off (remove)
|
||||
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
|
||||
await exec(schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
||||
} else {
|
||||
// Different emoji — replace
|
||||
db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id);
|
||||
await exec(schema, 'UPDATE reactions SET emoji=$1 WHERE id=$2', [emoji, existing.id]);
|
||||
}
|
||||
} else {
|
||||
// No existing reaction — insert
|
||||
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
|
||||
await exec(schema,
|
||||
'INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)',
|
||||
[messageId, userId, 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(messageId);
|
||||
const reactions = await query(schema, `
|
||||
SELECT r.emoji, r.user_id, u.name AS user_name
|
||||
FROM reactions r JOIN users u ON r.user_id=u.id
|
||||
WHERE r.message_id=$1
|
||||
`, [messageId]);
|
||||
|
||||
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
|
||||
} catch (e) {
|
||||
console.error('[Socket] reaction:toggle error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle message delete
|
||||
socket.on('message:delete', (data) => {
|
||||
const { messageId } = data;
|
||||
const db = getDb();
|
||||
const message = db.prepare(`
|
||||
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
|
||||
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
|
||||
`).get(messageId);
|
||||
// ── Message delete ──────────────────────────────────────────────────────────
|
||||
socket.on('message:delete', async ({ messageId }) => {
|
||||
try {
|
||||
const message = await queryOne(schema, `
|
||||
SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id, g.is_direct
|
||||
FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1
|
||||
`, [messageId]);
|
||||
if (!message) return;
|
||||
|
||||
const isAdmin = socket.user.role === 'admin';
|
||||
const isOwner = message.group_owner_id === userId;
|
||||
const isAuthor = message.user_id === userId;
|
||||
|
||||
// Rules:
|
||||
// 1. Author can always delete their own message
|
||||
// 2. Admin can delete in any public group or any group they're a member of
|
||||
// 3. Group owner can delete any message in their group
|
||||
// 4. In direct messages: author + owner rules apply (no blanket block)
|
||||
let canDelete = isAuthor || isOwner;
|
||||
|
||||
if (!canDelete && isAdmin) {
|
||||
if (message.group_type === 'public') {
|
||||
canDelete = true;
|
||||
} else {
|
||||
// Admin can delete in private/direct groups they're a member of
|
||||
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
|
||||
const membership = await queryOne(schema,
|
||||
'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2',
|
||||
[message.group_id, userId]
|
||||
);
|
||||
if (membership) canDelete = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDelete) return;
|
||||
|
||||
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(messageId);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle typing
|
||||
// ── Typing indicators ───────────────────────────────────────────────────────
|
||||
socket.on('typing:start', ({ groupId }) => {
|
||||
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
|
||||
});
|
||||
@@ -342,24 +352,38 @@ io.on('connection', (socket) => {
|
||||
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
|
||||
});
|
||||
|
||||
// Get online users
|
||||
socket.on('users:online', () => {
|
||||
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
// ── Disconnect ──────────────────────────────────────────────────────────────
|
||||
socket.on('disconnect', () => {
|
||||
if (onlineUsers.has(userId)) {
|
||||
onlineUsers.get(userId).delete(socket.id);
|
||||
if (onlineUsers.get(userId).size === 0) {
|
||||
onlineUsers.delete(userId);
|
||||
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
|
||||
exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {});
|
||||
io.emit('user:offline', { userId });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`jama server running on port ${PORT}`);
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
initDb().then(async () => {
|
||||
if (APP_TYPE === 'host') {
|
||||
try {
|
||||
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
||||
refreshTenantCache(tenants);
|
||||
console.log(`[Server] Loaded ${tenants.length} tenant(s) into domain cache`);
|
||||
} catch (e) {
|
||||
console.warn('[Server] Could not load tenant cache:', e.message);
|
||||
}
|
||||
}
|
||||
server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`));
|
||||
}).catch(err => {
|
||||
console.error('[Server] DB init failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { io };
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
|
||||
// Classify a User-Agent string into 'mobile' or 'desktop'.
|
||||
// Tablets are treated as mobile (one shared slot).
|
||||
function getDeviceClass(ua) {
|
||||
if (!ua) return 'desktop';
|
||||
const s = ua.toLowerCase();
|
||||
@@ -13,22 +11,19 @@ function getDeviceClass(ua) {
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
async function authMiddleware(req, res, next) {
|
||||
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
|
||||
const user = await queryOne(req.schema,
|
||||
"SELECT * FROM users WHERE id = $1 AND status = 'active'", [decoded.id]
|
||||
);
|
||||
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
|
||||
|
||||
// Per-device enforcement: token must match an active session row
|
||||
const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token);
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
||||
}
|
||||
|
||||
const session = await queryOne(req.schema,
|
||||
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2', [decoded.id, token]
|
||||
);
|
||||
if (!session) return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
||||
req.user = user;
|
||||
req.token = token;
|
||||
req.device = session.device;
|
||||
@@ -43,13 +38,15 @@ function adminMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// Allows admins OR members of groups designated as Tool Managers
|
||||
function teamManagerMiddleware(req, res, next) {
|
||||
async function teamManagerMiddleware(req, res, next) {
|
||||
if (req.user?.role === 'admin') return next();
|
||||
const db = getDb();
|
||||
// Prefer unified key, fall back to legacy keys for older installs
|
||||
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
|
||||
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
|
||||
try {
|
||||
const tmSetting = await queryOne(req.schema,
|
||||
"SELECT value FROM settings WHERE key = 'team_tool_managers'"
|
||||
);
|
||||
const gmSetting = await queryOne(req.schema,
|
||||
"SELECT value FROM settings WHERE key = 'team_group_managers'"
|
||||
);
|
||||
const allowedGroupIds = [
|
||||
...new Set([
|
||||
...JSON.parse(tmSetting?.value || '[]'),
|
||||
@@ -57,38 +54,41 @@ function teamManagerMiddleware(req, res, next) {
|
||||
])
|
||||
];
|
||||
if (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
|
||||
const member = db.prepare(`
|
||||
SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${allowedGroupIds.map(() => '?').join(',')})
|
||||
`).get(req.user.id, ...allowedGroupIds);
|
||||
const placeholders = allowedGroupIds.map((_, i) => `$${i + 2}`).join(',');
|
||||
const member = await queryOne(req.schema,
|
||||
`SELECT 1 FROM user_group_members WHERE user_id = $1 AND user_group_id IN (${placeholders})`,
|
||||
[req.user.id, ...allowedGroupIds]
|
||||
);
|
||||
if (!member) return res.status(403).json({ error: 'Access denied' });
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken(userId) {
|
||||
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
// Upsert the active session for this user+device class.
|
||||
// Displaces any prior session on the same device class; the other device class is unaffected.
|
||||
function setActiveSession(userId, token, userAgent) {
|
||||
const db = getDb();
|
||||
async function setActiveSession(schema, userId, token, userAgent) {
|
||||
const device = getDeviceClass(userAgent);
|
||||
db.prepare(`
|
||||
await exec(schema, `
|
||||
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, device) DO UPDATE SET token = ?, ua = ?, created_at = datetime('now')
|
||||
`).run(userId, device, token, userAgent || null, token, userAgent || null);
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (user_id, device) DO UPDATE SET token = $3, ua = $4, created_at = NOW()
|
||||
`, [userId, device, token, userAgent || null]);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Clear one device slot on logout, or all slots (no device arg) for suspend/delete
|
||||
function clearActiveSession(userId, device) {
|
||||
const db = getDb();
|
||||
async function clearActiveSession(schema, userId, device) {
|
||||
if (device) {
|
||||
db.prepare('DELETE FROM active_sessions WHERE user_id = ? AND device = ?').run(userId, device);
|
||||
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1 AND device = $2', [userId, device]);
|
||||
} else {
|
||||
db.prepare('DELETE FROM active_sessions WHERE user_id = ?').run(userId);
|
||||
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1', [userId]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminMiddleware, teamManagerMiddleware, generateToken, setActiveSession, clearActiveSession, getDeviceClass };
|
||||
module.exports = {
|
||||
authMiddleware, adminMiddleware, teamManagerMiddleware,
|
||||
generateToken, setActiveSession, clearActiveSession, getDeviceClass,
|
||||
};
|
||||
|
||||
@@ -1,557 +1,384 @@
|
||||
const Database = require('better-sqlite3-multiple-ciphers');
|
||||
const path = require('path');
|
||||
/**
|
||||
* 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 bcrypt = require('bcryptjs');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/app/data/jama.db';
|
||||
const DB_KEY = process.env.DB_KEY || '';
|
||||
const APP_TYPE = process.env.APP_TYPE || 'selfhost';
|
||||
|
||||
let db;
|
||||
// ── Connection pool ───────────────────────────────────────────────────────────
|
||||
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
// Ensure the data directory exists before opening the DB
|
||||
const dir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`[DB] Created data directory: ${dir}`);
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'jama',
|
||||
user: process.env.DB_USER || 'jama',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected pool error:', err.message);
|
||||
});
|
||||
|
||||
// ── Schema resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
const tenantDomainCache = new Map();
|
||||
|
||||
function resolveSchema(req) {
|
||||
if (APP_TYPE === 'selfhost') return 'public';
|
||||
|
||||
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
|
||||
|
||||
// Internal requests (Docker health checks, localhost) → public schema
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||
|
||||
// Subdomain: team1.jamachat.com → tenant_team1
|
||||
if (host.endsWith(`.${baseDomain}`)) {
|
||||
const slug = host.slice(0, -(baseDomain.length + 1));
|
||||
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
||||
return `tenant_${slug.replace(/[^a-z0-9]/g, '_')}`;
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
if (DB_KEY) {
|
||||
// Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser
|
||||
// Must be applied before any other DB access
|
||||
const safeKey = DB_KEY.replace(/'/g, "''");
|
||||
db.pragma(`cipher='sqlcipher'`);
|
||||
db.pragma(`legacy=4`);
|
||||
db.pragma(`key='${safeKey}'`);
|
||||
console.log('[DB] Encryption key applied (SQLCipher4)');
|
||||
} else {
|
||||
console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted');
|
||||
}
|
||||
const journalMode = db.pragma('journal_mode = WAL', { simple: true });
|
||||
if (journalMode !== 'wal') {
|
||||
console.warn(`[DB] WARNING: journal_mode is '${journalMode}', expected 'wal' — performance may be degraded`);
|
||||
}
|
||||
db.pragma('synchronous = NORMAL'); // safe with WAL, faster than FULL
|
||||
db.pragma('cache_size = -8000'); // 8MB page cache
|
||||
db.pragma('foreign_keys = ON');
|
||||
console.log(`[DB] Opened database at ${DB_PATH} (journal=${journalMode})`);
|
||||
}
|
||||
return db;
|
||||
|
||||
// Custom domain lookup (populated from host admin DB)
|
||||
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host);
|
||||
|
||||
// Base domain → public schema (host admin panel)
|
||||
if (host === baseDomain || host === `www.${baseDomain}`) return 'public';
|
||||
|
||||
throw new Error(`Unknown tenant for host: ${host}`);
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
const db = getDb();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 1,
|
||||
avatar TEXT,
|
||||
about_me TEXT,
|
||||
display_name TEXT,
|
||||
hide_admin_tag INTEGER NOT NULL DEFAULT 0,
|
||||
allow_dm INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'public',
|
||||
owner_id INTEGER,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_readonly INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(group_id, user_id),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
image_url TEXT,
|
||||
reply_to_id INTEGER,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
link_preview TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (reply_to_id) REFERENCES messages(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(message_id, user_id, emoji),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
group_id INTEGER,
|
||||
from_user_id INTEGER,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
device TEXT NOT NULL DEFAULT 'desktop',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, device),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User groups (admin-managed, separate from chat groups)
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Members of user groups
|
||||
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_group_id, user_id),
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Multi-group DMs: admin-created DMs whose members are user groups
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER, -- paired private group in groups table
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- User groups that are members of a multi-group DM
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
|
||||
multi_group_dm_id INTEGER NOT NULL,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (multi_group_dm_id, user_group_id),
|
||||
FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Initialize default settings
|
||||
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
insertSetting.run('app_name', process.env.APP_NAME || 'jama');
|
||||
insertSetting.run('logo_url', '');
|
||||
insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false');
|
||||
insertSetting.run('icon_newchat', '');
|
||||
insertSetting.run('icon_groupinfo', '');
|
||||
insertSetting.run('pwa_icon_192', '');
|
||||
insertSetting.run('pwa_icon_512', '');
|
||||
insertSetting.run('color_title', '');
|
||||
insertSetting.run('color_title_dark', '');
|
||||
insertSetting.run('color_avatar_public', '');
|
||||
insertSetting.run('color_avatar_dm', '');
|
||||
insertSetting.run('registration_code', '');
|
||||
insertSetting.run('feature_branding', 'false');
|
||||
insertSetting.run('feature_group_manager', 'false');
|
||||
insertSetting.run('feature_schedule_manager', 'false');
|
||||
insertSetting.run('app_type', 'JAMA-Chat');
|
||||
insertSetting.run('team_group_managers', '');
|
||||
insertSetting.run('team_schedule_managers', '');
|
||||
insertSetting.run('team_tool_managers', '');
|
||||
|
||||
// Migration: add hide_admin_tag if upgrading from older version
|
||||
try {
|
||||
db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added hide_admin_tag column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: add allow_dm if upgrading from older version
|
||||
try {
|
||||
db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1");
|
||||
console.log('[DB] Migration: added allow_dm column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: replace single-session active_sessions with per-device version
|
||||
try {
|
||||
const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name);
|
||||
if (!cols.includes('device')) {
|
||||
db.exec("DROP TABLE IF EXISTS active_sessions");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
user_id INTEGER NOT NULL,
|
||||
device TEXT NOT NULL DEFAULT 'desktop',
|
||||
token TEXT NOT NULL,
|
||||
ua TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, device),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
console.log('[DB] Migration: rebuilt active_sessions for per-device sessions');
|
||||
function refreshTenantCache(tenants) {
|
||||
tenantDomainCache.clear();
|
||||
for (const t of tenants) {
|
||||
if (t.custom_domain) {
|
||||
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
|
||||
}
|
||||
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
|
||||
|
||||
// Migration: add is_direct for user-to-user direct messages
|
||||
try {
|
||||
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added is_direct column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: store both peer IDs so direct-message names survive member leave
|
||||
try {
|
||||
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
|
||||
console.log('[DB] Migration: added direct_peer1_id column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
try {
|
||||
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
|
||||
console.log('[DB] Migration: added direct_peer2_id column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: last_online timestamp per user
|
||||
try {
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
|
||||
console.log('[DB] Migration: added last_online column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: help_dismissed preference per user
|
||||
try {
|
||||
db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added help_dismissed column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Migration: user-customised group display names (per-user, per-group)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_group_names (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
)
|
||||
`);
|
||||
console.log('[DB] Migration: user_group_names table ready');
|
||||
} catch (e) { console.error('[DB] user_group_names migration error:', e.message); }
|
||||
|
||||
// Migration: pinned conversations (per-user, pins a group to top of sidebar)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pinned_conversations (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
console.log('[DB] Migration: pinned_conversations table ready');
|
||||
} catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); }
|
||||
|
||||
// Migration: is_managed flag on groups (admin-managed DMs via Group Manager)
|
||||
try {
|
||||
db.exec("ALTER TABLE groups ADD COLUMN is_managed INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added is_managed column to groups');
|
||||
} catch (e) { /* already exists */ }
|
||||
|
||||
// Migration: is_multi_group flag — distinguishes multi-group DMs from user-group DMs
|
||||
try {
|
||||
db.exec("ALTER TABLE groups ADD COLUMN is_multi_group INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added is_multi_group column to groups');
|
||||
} catch (e) { /* already exists */ }
|
||||
// Back-fill feature_schedule_manager for installs that registered before this setting existed
|
||||
try {
|
||||
const appType = db.prepare("SELECT value FROM settings WHERE key = 'app_type'").get();
|
||||
if (appType && appType.value === 'JAMA-Team') {
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('feature_schedule_manager', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true' WHERE value = 'false'").run();
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Back-fill is_multi_group for any existing multi-group DM groups
|
||||
try {
|
||||
db.exec("UPDATE groups SET is_multi_group = 1 WHERE id IN (SELECT dm_group_id FROM multi_group_dms WHERE dm_group_id IS NOT NULL)");
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Migration: user_groups and user_group_members tables
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_group_id, user_id),
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
|
||||
multi_group_dm_id INTEGER NOT NULL,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (multi_group_dm_id, user_group_id),
|
||||
FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
// Migration: add joined_at to user_group_members if missing
|
||||
try { db.exec("ALTER TABLE user_group_members ADD COLUMN joined_at TEXT NOT NULL DEFAULT (datetime('now'))"); } catch(e) {}
|
||||
console.log('[DB] Migration: user_groups tables ready');
|
||||
} catch (e) { console.error('[DB] user_groups migration error:', e.message); }
|
||||
|
||||
// ── Schedule Manager ────────────────────────────────────────────────────────
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS event_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
colour TEXT NOT NULL DEFAULT '#6366f1',
|
||||
default_user_group_id INTEGER,
|
||||
default_duration_hrs REAL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_protected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
event_type_id INTEGER,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 1,
|
||||
track_availability INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS event_user_groups (
|
||||
event_id INTEGER NOT NULL,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (event_id, user_group_id),
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS event_availability (
|
||||
event_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (event_id, user_id),
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
// Migration: add columns if missing (must run before inserts)
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } catch(e) {}
|
||||
// Delete the legacy "Default" type — "Event" is the canonical default
|
||||
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
|
||||
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
|
||||
// Remove duplicates — keep the one with is_default=1
|
||||
const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all();
|
||||
if (evtTypes.length > 1) {
|
||||
for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id);
|
||||
}
|
||||
// Ensure built-in types are correct
|
||||
db.prepare("UPDATE event_types SET is_protected = 1, is_default = 1, default_duration_hrs = 1.0 WHERE name = 'Event'").run();
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run();
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").run();
|
||||
console.log('[DB] Schedule Manager tables ready');
|
||||
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
|
||||
|
||||
console.log('[DB] Schema initialized');
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
const db = getDb();
|
||||
// ── Schema name safety guard ──────────────────────────────────────────────────
|
||||
|
||||
// Strip any surrounding quotes from env vars (common docker-compose mistake)
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim();
|
||||
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
|
||||
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
|
||||
function assertSafeSchema(schema) {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
|
||||
throw new Error(`Unsafe schema name rejected: ${schema}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core query helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async function query(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
const result = await client.query(sql, params);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function queryOne(schema, sql, params = []) {
|
||||
const rows = await query(schema, sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function queryResult(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
return await client.query(sql, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function exec(schema, sql, params = []) {
|
||||
await query(schema, sql, params);
|
||||
}
|
||||
|
||||
async function withTransaction(schema, callback) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration runner ──────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureSchema(schema) {
|
||||
assertSafeSchema(schema);
|
||||
// Use a direct client outside of search_path for schema creation
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigrations(schema) {
|
||||
await ensureSchema(schema);
|
||||
|
||||
await exec(schema, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
|
||||
const appliedSet = new Set(applied.map(r => r.version));
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const files = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const m = file.match(/^(\d+)_/);
|
||||
if (!m) continue;
|
||||
const version = parseInt(m[1]);
|
||||
if (appliedSet.has(version)) continue;
|
||||
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
|
||||
|
||||
await withTransaction(schema, async (client) => {
|
||||
await client.query(sql);
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
|
||||
[version, file]
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[DB:${schema}] Migration ${version} done`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seeding ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function seedSettings(schema) {
|
||||
const defaults = [
|
||||
['app_name', process.env.APP_NAME || 'jama'],
|
||||
['logo_url', ''],
|
||||
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
||||
['icon_newchat', ''],
|
||||
['icon_groupinfo', ''],
|
||||
['pwa_icon_192', ''],
|
||||
['pwa_icon_512', ''],
|
||||
['color_title', ''],
|
||||
['color_title_dark', ''],
|
||||
['color_avatar_public', ''],
|
||||
['color_avatar_dm', ''],
|
||||
['registration_code', ''],
|
||||
['feature_branding', 'false'],
|
||||
['feature_group_manager', 'false'],
|
||||
['feature_schedule_manager', 'false'],
|
||||
['app_type', 'JAMA-Chat'],
|
||||
['team_group_managers', ''],
|
||||
['team_schedule_managers', ''],
|
||||
['team_tool_managers', ''],
|
||||
];
|
||||
for (const [key, value] of defaults) {
|
||||
await exec(schema,
|
||||
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedEventTypes(schema) {
|
||||
await exec(schema, `
|
||||
INSERT INTO event_types (name, colour, is_default, is_protected, default_duration_hrs)
|
||||
VALUES ('Event', '#6366f1', TRUE, TRUE, 1.0)
|
||||
ON CONFLICT (name) DO UPDATE SET is_default=TRUE, is_protected=TRUE, default_duration_hrs=1.0
|
||||
`);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
}
|
||||
|
||||
async function seedAdmin(schema) {
|
||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
|
||||
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||
|
||||
console.log(`[DB] Checking for default admin (${adminEmail})...`);
|
||||
console.log(`[DB:${schema}] Checking for default admin (${adminEmail})...`);
|
||||
|
||||
const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get();
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT * FROM users WHERE is_default_admin = TRUE'
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
const result = db.prepare(`
|
||||
const ur = await queryResult(schema, `
|
||||
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
|
||||
VALUES (?, ?, ?, 'admin', 'active', 1, 1)
|
||||
`).run(adminName, adminEmail, hash);
|
||||
VALUES ($1, $2, $3, 'admin', 'active', TRUE, TRUE) RETURNING id
|
||||
`, [adminName, adminEmail, hash]);
|
||||
const adminId = ur.rows[0].id;
|
||||
|
||||
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
|
||||
const chatName = strip(process.env.DEFCHAT_NAME) || 'General Chat';
|
||||
const gr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, is_default, owner_id) VALUES ($1, 'public', TRUE, $2) RETURNING id",
|
||||
[chatName, adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[gr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// Create default public group
|
||||
const groupResult = db.prepare(`
|
||||
INSERT INTO groups (name, type, is_default, owner_id)
|
||||
VALUES (?, 'public', 1, ?)
|
||||
`).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid);
|
||||
const sr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', $1, FALSE) RETURNING id",
|
||||
[adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[sr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// Add admin to default group
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
|
||||
.run(groupResult.lastInsertRowid, result.lastInsertRowid);
|
||||
|
||||
console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`);
|
||||
seedSupportGroup();
|
||||
} catch (err) {
|
||||
console.error('[DB] ERROR creating default admin:', err.message);
|
||||
}
|
||||
console.log(`[DB:${schema}] Default admin + groups created`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DB] Default admin already exists (id=${existing.id})`);
|
||||
|
||||
// Handle ADMPW_RESET
|
||||
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
|
||||
if (pwReset) {
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
db.prepare(`
|
||||
UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now')
|
||||
WHERE is_default_admin = 1
|
||||
`).run(hash);
|
||||
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
||||
console.log('[DB] Admin password reset via ADMPW_RESET=true');
|
||||
await exec(schema,
|
||||
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||
[hash]
|
||||
);
|
||||
await exec(schema, "UPDATE settings SET value='true', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
console.log(`[DB:${schema}] Admin password reset`);
|
||||
} else {
|
||||
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
||||
await exec(schema, "UPDATE settings SET value='false', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
}
|
||||
}
|
||||
|
||||
function addUserToPublicGroups(userId) {
|
||||
const db = getDb();
|
||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const g of publicGroups) {
|
||||
insert.run(g.id, userId);
|
||||
// ── Main init (called on server startup) ─────────────────────────────────────
|
||||
|
||||
async function initDb() {
|
||||
// Wait for Postgres to be ready (up to 30s)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
console.log('[DB] Connected to Postgres');
|
||||
break;
|
||||
} catch (e) {
|
||||
console.log(`[DB] Waiting for Postgres... (${i + 1}/30)`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await runMigrations('public');
|
||||
await seedSettings('public');
|
||||
await seedEventTypes('public');
|
||||
await seedAdmin('public');
|
||||
|
||||
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
|
||||
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
|
||||
if (APP_TYPE === 'host') {
|
||||
const hostPlan = [
|
||||
['app_type', 'JAMA-Team'],
|
||||
['feature_branding', 'true'],
|
||||
['feature_group_manager', 'true'],
|
||||
['feature_schedule_manager', 'true'],
|
||||
];
|
||||
for (const [key, value] of hostPlan) {
|
||||
await exec('public',
|
||||
'INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan');
|
||||
}
|
||||
|
||||
console.log('[DB] Initialisation complete');
|
||||
}
|
||||
|
||||
// ── Helper functions used by routes ──────────────────────────────────────────
|
||||
|
||||
async function addUserToPublicGroups(schema, userId) {
|
||||
const groups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||
for (const g of groups) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[g.id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedSupportGroup() {
|
||||
const db = getDb();
|
||||
async function getOrCreateSupportGroup(schema) {
|
||||
const g = await queryOne(schema, "SELECT id FROM groups WHERE name='Support' AND type='private'");
|
||||
if (g) return g.id;
|
||||
|
||||
// Create the Support group if it doesn't exist
|
||||
const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
||||
const admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||
if (!admin) return null;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO groups (name, type, owner_id, is_default)
|
||||
VALUES ('Support', 'private', ?, 0)
|
||||
`).run(admin.id);
|
||||
|
||||
const groupId = result.lastInsertRowid;
|
||||
|
||||
// Add all current admins to the Support group
|
||||
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const a of admins) insert.run(groupId, a.id);
|
||||
|
||||
console.log('[DB] Support group created');
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support','private',$1,FALSE) RETURNING id",
|
||||
[admin.id]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
const admins = await query(schema, "SELECT id FROM users WHERE role='admin' AND status='active'");
|
||||
for (const a of admins) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[groupId, a.id]
|
||||
);
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
|
||||
function getOrCreateSupportGroup() {
|
||||
const db = getDb();
|
||||
const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
|
||||
if (group) return group.id;
|
||||
return seedSupportGroup();
|
||||
// ── Tenant middleware ─────────────────────────────────────────────────────────
|
||||
|
||||
function tenantMiddleware(req, res, next) {
|
||||
try {
|
||||
req.schema = resolveSchema(req);
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Tenant]', err.message);
|
||||
res.status(404).json({ error: 'Unknown tenant' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };
|
||||
module.exports = {
|
||||
query, queryOne, queryResult, exec, withTransaction,
|
||||
initDb, runMigrations, ensureSchema,
|
||||
tenantMiddleware, resolveSchema, refreshTenantCache,
|
||||
APP_TYPE, pool,
|
||||
addUserToPublicGroups, getOrCreateSupportGroup,
|
||||
seedSettings, seedEventTypes, seedAdmin,
|
||||
};
|
||||
|
||||
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 DEFAULTS = {
|
||||
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
|
||||
built_with: 'Node.js · Express · Socket.io · PostgreSQL · React · Vite · Claude.ai',
|
||||
developer: 'Ricky Stretch',
|
||||
license: 'AGPL 3.0',
|
||||
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
|
||||
|
||||
@@ -1,130 +1,100 @@
|
||||
const express = require('express');
|
||||
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');
|
||||
|
||||
module.exports = function(io) {
|
||||
const router = express.Router();
|
||||
const router = express.Router();
|
||||
|
||||
// Login
|
||||
router.post('/login', (req, res) => {
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password, rememberMe } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
if (user.status === 'suspended') {
|
||||
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
||||
return res.status(403).json({
|
||||
error: 'suspended',
|
||||
adminEmail: adminUser?.email
|
||||
});
|
||||
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' });
|
||||
|
||||
const valid = bcrypt.compareSync(password, user.password);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
if (!bcrypt.compareSync(password, user.password))
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const ua = req.headers['user-agent'] || '';
|
||||
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
|
||||
// Kick any live socket on the same device class — it now holds a stale token
|
||||
if (io) {
|
||||
io.to(`user:${user.id}`).emit('session:displaced', { device });
|
||||
}
|
||||
const device = await setActiveSession(req.schema, user.id, token, ua);
|
||||
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
|
||||
res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', authMiddleware, (req, res) => {
|
||||
// Change password
|
||||
router.post('/change-password', authMiddleware, async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!bcrypt.compareSync(currentPassword, user.password)) {
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE id = $1', [req.user.id]);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password))
|
||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
|
||||
if (newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
const hash = bcrypt.hashSync(newPassword, 10);
|
||||
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
|
||||
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
const { password, ...user } = req.user;
|
||||
res.json({ user });
|
||||
});
|
||||
});
|
||||
|
||||
// Logout — clear active session for this device class only
|
||||
router.post('/logout', authMiddleware, (req, res) => {
|
||||
clearActiveSession(req.user.id, req.device);
|
||||
// Logout
|
||||
router.post('/logout', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await clearActiveSession(req.schema, req.user.id, req.device);
|
||||
res.json({ success: true });
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Public support contact form — no auth required
|
||||
router.post('/support', (req, res) => {
|
||||
// Support contact form
|
||||
router.post('/support', async (req, res) => {
|
||||
const { name, email, message } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !message?.trim()) {
|
||||
if (!name?.trim() || !email?.trim() || !message?.trim())
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
if (message.trim().length > 2000) {
|
||||
if (message.trim().length > 2000)
|
||||
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get or create the Support group
|
||||
const groupId = getOrCreateSupportGroup();
|
||||
try {
|
||||
const groupId = await getOrCreateSupportGroup(req.schema);
|
||||
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();
|
||||
const admin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||
if (!admin) return res.status(500).json({ error: 'No admin configured' });
|
||||
|
||||
// Format the support message
|
||||
const content = `📬 **Support Request**
|
||||
**Name:** ${name.trim()}
|
||||
**Email:** ${email.trim()}
|
||||
const content = `📬 **Support Request**\n**Name:** ${name.trim()}\n**Email:** ${email.trim()}\n\n${message.trim()}`;
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id, user_id, content, type) VALUES ($1,$2,$3,'text') RETURNING id",
|
||||
[groupId, admin.id, content]
|
||||
);
|
||||
const newMsg = await queryOne(req.schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
|
||||
`, [mr.rows[0].id]);
|
||||
if (newMsg) { newMsg.reactions = []; io.to(`group:${groupId}`).emit('message:new', newMsg); }
|
||||
|
||||
${message.trim()}`;
|
||||
const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'");
|
||||
for (const a of admins) io.to(`user:${a.id}`).emit('notification:new', { type: 'support', groupId });
|
||||
|
||||
const 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 });
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,449 +1,317 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Helper: emit group:new to all members of a group
|
||||
function emitGroupNew(io, groupId) {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||
catch (e) { console.warn('[Groups] Could not delete image:', e.message); }
|
||||
}
|
||||
|
||||
module.exports = (io) => {
|
||||
|
||||
async function emitGroupNew(schema, io, groupId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return;
|
||||
if (group.type === 'public') {
|
||||
io.emit('group:new', { group });
|
||||
} else {
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
||||
for (const m of members) {
|
||||
io.to(`user:${m.user_id}`).emit('group:new', { group });
|
||||
}
|
||||
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||
for (const m of members) io.to(`user:${m.user_id}`).emit('group:new', { group });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an uploaded image file from disk
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
try {
|
||||
const filePath = '/app' + imageUrl;
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch (e) {
|
||||
console.warn('[Groups] Could not delete image file:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: emit group:deleted to all members
|
||||
function emitGroupDeleted(io, groupId, members) {
|
||||
for (const uid of members) {
|
||||
io.to(`user:${uid}`).emit('group:deleted', { groupId });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: emit group:updated to all members
|
||||
function emitGroupUpdated(io, groupId) {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
async function emitGroupUpdated(schema, io, groupId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return;
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
||||
const uids = group.type === 'public'
|
||||
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
|
||||
: members;
|
||||
for (const m of uids) {
|
||||
io.to(`user:${m.user_id}`).emit('group:updated', { group });
|
||||
let uids;
|
||||
if (group.type === 'public') {
|
||||
uids = await query(schema, "SELECT id AS user_id FROM users WHERE status='active'");
|
||||
} else {
|
||||
uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||
}
|
||||
for (const m of uids) io.to(`user:${m.user_id}`).emit('group:updated', { group });
|
||||
}
|
||||
|
||||
// Inject io into routes
|
||||
|
||||
module.exports = (io) => {
|
||||
|
||||
// Get all groups for current user
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
// GET all groups for current user
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const publicGroups = db.prepare(`
|
||||
const publicGroups = await query(req.schema, `
|
||||
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();
|
||||
(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
|
||||
`);
|
||||
|
||||
// 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'
|
||||
const privateGroupsRaw = await query(req.schema, `
|
||||
SELECT g.*, u.name AS owner_name,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id
|
||||
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
||||
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
|
||||
ORDER BY last_message_at DESC NULLS LAST
|
||||
`).all(userId);
|
||||
`, [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 => {
|
||||
const privateGroups = await Promise.all(privateGroupsRaw.map(async 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);
|
||||
const peers = await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 2', [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;
|
||||
await exec(req.schema, 'UPDATE groups SET direct_peer1_id=$1, direct_peer2_id=$2 WHERE id=$3', [peers[0].user_id, peers[1].user_id, g.id]);
|
||||
g.direct_peer1_id = peers[0].user_id; g.direct_peer2_id = peers[1].user_id;
|
||||
}
|
||||
}
|
||||
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
|
||||
if (otherUserId) {
|
||||
const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(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; // null if no custom display name set
|
||||
g.peer_avatar = other.avatar || null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// Create group
|
||||
router.post('/', authMiddleware, (req, res) => {
|
||||
// POST create group
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
const { name, type, memberIds, isReadonly, isDirect } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (type === 'public' && req.user.role !== 'admin') {
|
||||
try {
|
||||
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;
|
||||
|
||||
// Check if a direct group already exists between these two users
|
||||
const existing = db.prepare(`
|
||||
// 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 = ?
|
||||
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
|
||||
WHERE g.is_direct = 1
|
||||
LIMIT 1
|
||||
`).get(userId, otherUserId);
|
||||
|
||||
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) {
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
|
||||
// Ensure current user is still a member (may have left)
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
|
||||
// Re-set readonly to false so both can post again
|
||||
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
|
||||
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) });
|
||||
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]) });
|
||||
}
|
||||
|
||||
// Get other user's display name for the group name (stored internally, overridden per-user on fetch)
|
||||
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
|
||||
const 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 result = db.prepare(`
|
||||
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
|
||||
VALUES (?, 'private', NULL, 0, 1, ?, ?)
|
||||
`).run(dmName, userId, otherUserId);
|
||||
|
||||
const groupId = result.lastInsertRowid;
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId);
|
||||
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
|
||||
// Notify both users via socket
|
||||
emitGroupNew(io, groupId);
|
||||
|
||||
return res.json({ group });
|
||||
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]) });
|
||||
}
|
||||
|
||||
// For private groups: check if exact same set of members already exists in a group
|
||||
if ((type === 'private' || !type) && !isDirect && memberIds && memberIds.length > 0) {
|
||||
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a, b) => a - b);
|
||||
const count = allMemberIds.length;
|
||||
|
||||
// Find all private non-direct groups where the creator is a member
|
||||
const candidates = db.prepare(`
|
||||
SELECT g.id FROM groups g
|
||||
JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ?
|
||||
WHERE g.type = 'private' AND g.is_direct = 0
|
||||
`).all(req.user.id);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const members = db.prepare(
|
||||
'SELECT user_id FROM group_members WHERE group_id = ? ORDER BY user_id'
|
||||
).all(candidate.id).map(r => r.user_id);
|
||||
if (members.length === count &&
|
||||
members.every((id, i) => id === allMemberIds[i])) {
|
||||
// Exact duplicate found — return the existing group
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(candidate.id);
|
||||
return res.json({ group, duplicate: true });
|
||||
}
|
||||
// Check for duplicate private group
|
||||
if ((type === 'private' || !type) && !isDirect && memberIds?.length > 0) {
|
||||
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a,b) => a-b);
|
||||
const candidates = await query(req.schema,
|
||||
'SELECT g.id FROM groups g JOIN group_members gm ON gm.group_id=g.id AND gm.user_id=$1 WHERE g.type=\'private\' AND g.is_direct=FALSE',
|
||||
[req.user.id]
|
||||
);
|
||||
for (const c of candidates) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 ORDER BY user_id', [c.id])).map(r => r.user_id);
|
||||
if (members.length === allMemberIds.length && members.every((id,i) => id === allMemberIds[i]))
|
||||
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [c.id]), duplicate: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
|
||||
|
||||
const groupId = result.lastInsertRowid;
|
||||
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO groups (name,type,owner_id,is_readonly,is_direct) VALUES ($1,$2,$3,$4,FALSE) RETURNING id',
|
||||
[name, type||'private', req.user.id, !!isReadonly]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
if (type === 'public') {
|
||||
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const u of allUsers) insert.run(groupId, u.id);
|
||||
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
||||
} else {
|
||||
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 exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, req.user.id]);
|
||||
if (memberIds?.length > 0) for (const uid of memberIds) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
|
||||
}
|
||||
}
|
||||
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
|
||||
// Notify all members via socket
|
||||
emitGroupNew(io, groupId);
|
||||
|
||||
res.json({ group });
|
||||
await emitGroupNew(req.schema, io, groupId);
|
||||
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Rename group
|
||||
router.patch('/:id/rename', authMiddleware, (req, res) => {
|
||||
// PATCH rename
|
||||
router.patch('/:id/rename', authMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
|
||||
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner can rename private group' });
|
||||
}
|
||||
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
|
||||
emitGroupUpdated(io, group.id);
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can rename' });
|
||||
await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name, group.id]);
|
||||
await emitGroupUpdated(req.schema, io, group.id);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get group members
|
||||
router.get('/:id/members', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
|
||||
FROM group_members gm
|
||||
JOIN users u ON gm.user_id = u.id
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY u.name ASC
|
||||
`).all(req.params.id);
|
||||
// GET members
|
||||
router.get('/:id/members', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const members = await query(req.schema,
|
||||
'SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status FROM group_members gm JOIN users u ON gm.user_id=u.id WHERE gm.group_id=$1 ORDER BY u.name ASC',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ members });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Add member to private group
|
||||
router.post('/:id/members', authMiddleware, (req, res) => {
|
||||
// POST add member
|
||||
router.post('/:id/members', authMiddleware, async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
|
||||
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner can add members' });
|
||||
}
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
|
||||
|
||||
// Post a system message so all members see who was added
|
||||
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' });
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]);
|
||||
const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
||||
const sysResult = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, type)
|
||||
VALUES (?, ?, ?, 'system')
|
||||
`).run(group.id, userId, `${addedName} has joined the conversation.`);
|
||||
const sysMsg = db.prepare(`
|
||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||
`).get(sysResult.lastInsertRowid);
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, userId, `${addedName} has joined the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.schema,
|
||||
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||
|
||||
// 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 });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove a member from a private group
|
||||
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
// DELETE remove member
|
||||
router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||
}
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||
const targetId = parseInt(req.params.userId);
|
||||
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||
|
||||
const removedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(targetId);
|
||||
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
|
||||
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
|
||||
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
|
||||
|
||||
// Post system message so remaining members see the removal notice
|
||||
const sysResult = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, type)
|
||||
VALUES (?, ?, ?, 'system')
|
||||
`).run(group.id, targetId, `${removedName} has been removed from the conversation.`);
|
||||
const sysMsg = db.prepare(`
|
||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||
`).get(sysResult.lastInsertRowid);
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, targetId, `${removedName} has been removed from the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.schema,
|
||||
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||
|
||||
// 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 });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Leave private group
|
||||
router.delete('/:id/leave', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
// DELETE leave
|
||||
router.delete('/:id/leave', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
|
||||
if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator. 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 leaverName = req.user.display_name || req.user.name;
|
||||
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
|
||||
|
||||
// Post a system message so remaining members see the leave notice
|
||||
const sysResult = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, type)
|
||||
VALUES (?, ?, ?, 'system')
|
||||
`).run(group.id, userId, `${leaverName} has left the conversation.`);
|
||||
|
||||
const sysMsg = db.prepare(`
|
||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||
`).get(sysResult.lastInsertRowid);
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, userId]);
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, userId, `${leaverName} has left the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.schema,
|
||||
'SELECT m.*,u.name AS user_name,u.display_name AS user_display_name,u.avatar AS user_avatar,u.role AS user_role,u.status AS user_status,u.hide_admin_tag AS user_hide_admin_tag,u.about_me AS user_about_me FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
|
||||
// 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);
|
||||
const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]);
|
||||
if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Admin take ownership
|
||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
// POST take-ownership
|
||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' });
|
||||
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id);
|
||||
await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [req.user.id, req.params.id]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete group
|
||||
router.delete('/:id', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
// DELETE group
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
|
||||
}
|
||||
|
||||
// Collect members before deleting
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id);
|
||||
// Add all active users for public groups
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can delete' });
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [group.id])).map(m => m.user_id);
|
||||
if (group.type === 'public') {
|
||||
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
||||
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
|
||||
const all = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||
for (const u of all) if (!members.includes(u.id)) members.push(u.id);
|
||||
}
|
||||
|
||||
// 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
|
||||
const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]);
|
||||
for (const msg of imageMessages) deleteImageFile(msg.image_url);
|
||||
|
||||
// Notify all affected users
|
||||
emitGroupDeleted(io, group.id, members);
|
||||
|
||||
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: group.id });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
|
||||
// Set or update user's custom name for a group
|
||||
router.patch('/:id/custom-name', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const groupId = parseInt(req.params.id);
|
||||
const userId = req.user.id;
|
||||
// PATCH custom-name
|
||||
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
// Empty name = remove custom name (revert to owner name)
|
||||
db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId);
|
||||
const groupId = parseInt(req.params.id), userId = req.user.id;
|
||||
try {
|
||||
if (!name?.trim()) {
|
||||
await exec(req.schema, 'DELETE FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, groupId]);
|
||||
return res.json({ success: true, name: null });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO user_group_names (user_id, group_id, name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name
|
||||
`).run(userId, groupId, name.trim());
|
||||
|
||||
await exec(req.schema,
|
||||
'INSERT INTO user_group_names (user_id,group_id,name) VALUES ($1,$2,$3) ON CONFLICT (user_id,group_id) DO UPDATE SET name=EXCLUDED.name',
|
||||
[userId, groupId, name.trim()]
|
||||
);
|
||||
res.json({ success: true, name: name.trim() });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../models/db');
|
||||
const router = express.Router();
|
||||
const { exec, queryOne } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// help.md lives inside the backend source tree — NOT in /app/data which is
|
||||
// volume-mounted and would hide files baked into the image at build time.
|
||||
const HELP_FILE = path.join(__dirname, '../data/help.md');
|
||||
|
||||
// GET /api/help — returns markdown content
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
let content = '';
|
||||
const filePath = HELP_FILE;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (e) {
|
||||
content = '# Getting Started\n\nHelp content is not available yet.';
|
||||
}
|
||||
try { content = fs.readFileSync(HELP_FILE, 'utf8'); }
|
||||
catch (e) { content = '# Getting Started\n\nHelp content is not available yet.'; }
|
||||
res.json({ content });
|
||||
});
|
||||
|
||||
// GET /api/help/status — returns whether user has dismissed help
|
||||
router.get('/status', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT help_dismissed FROM users WHERE id = ?').get(req.user.id);
|
||||
router.get('/status', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT help_dismissed FROM users WHERE id = $1', [req.user.id]);
|
||||
res.json({ dismissed: !!user?.help_dismissed });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /api/help/dismiss — set help_dismissed for current user
|
||||
router.post('/dismiss', authMiddleware, (req, res) => {
|
||||
router.post('/dismiss', authMiddleware, async (req, res) => {
|
||||
const { dismissed } = req.body;
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET help_dismissed = ? WHERE id = ?")
|
||||
.run(dismissed ? 1 : 0, req.user.id);
|
||||
try {
|
||||
await exec(req.schema, 'UPDATE users SET help_dismissed = $1 WHERE id = $2', [!!dismissed, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
312
backend/src/routes/host.js
Normal file
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;
|
||||
@@ -2,218 +2,172 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
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) {
|
||||
if (!imageUrl) return;
|
||||
try {
|
||||
const filePath = '/app' + imageUrl; // imageUrl is like /uploads/images/img_xxx.jpg
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch (e) {
|
||||
console.warn('[Messages] Could not delete image file:', e.message);
|
||||
}
|
||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||
catch (e) { console.warn('[Messages] Could not delete image:', e.message); }
|
||||
}
|
||||
|
||||
module.exports = function(io) {
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const imgStorage = multer.diskStorage({
|
||||
const imgStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/images',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
|
||||
}
|
||||
});
|
||||
const uploadImage = multer({
|
||||
storage: imgStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
});
|
||||
filename: (req, file, cb) => cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2,6)}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadImage = multer({ storage: imgStorage, limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
function getUserForMessage(db, userId) {
|
||||
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
|
||||
}
|
||||
|
||||
function canAccessGroup(db, groupId, userId) {
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
async function canAccessGroup(schema, groupId, userId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return null;
|
||||
if (group.type === 'public') return group;
|
||||
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
|
||||
if (!member) return null;
|
||||
return group;
|
||||
}
|
||||
const member = await queryOne(schema, 'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2', [groupId, userId]);
|
||||
return member ? group : null;
|
||||
}
|
||||
|
||||
// Get messages for group
|
||||
router.get('/group/:groupId', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
||||
// GET messages for group
|
||||
router.get('/group/:groupId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
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'
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
let query = `
|
||||
let sql = `
|
||||
SELECT m.*,
|
||||
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm,
|
||||
rm.content as reply_content, rm.image_url as reply_image_url,
|
||||
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
|
||||
rm.is_deleted as reply_is_deleted
|
||||
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 = ?
|
||||
WHERE m.group_id = $1
|
||||
`;
|
||||
const params = [req.params.groupId];
|
||||
|
||||
// Enforce join-date visibility for managed groups
|
||||
if (joinedAt) {
|
||||
query += ` AND date(m.created_at) >= ?`;
|
||||
params.push(joinedAt);
|
||||
}
|
||||
|
||||
if (before) {
|
||||
query += ' AND m.id < ?';
|
||||
params.push(before);
|
||||
}
|
||||
|
||||
query += ' ORDER BY m.created_at DESC LIMIT ?';
|
||||
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));
|
||||
|
||||
const messages = db.prepare(query).all(...params);
|
||||
|
||||
// Get reactions for these messages
|
||||
const messages = await query(req.schema, sql, params);
|
||||
for (const msg of messages) {
|
||||
msg.reactions = db.prepare(`
|
||||
SELECT r.emoji, r.user_id, u.name as user_name
|
||||
FROM reactions r JOIN users u ON r.user_id = u.id
|
||||
WHERE r.message_id = ?
|
||||
`).all(msg.id);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// Send message
|
||||
router.post('/group/:groupId', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
||||
// 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: 'This group is read-only' });
|
||||
|
||||
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 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);
|
||||
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// 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);
|
||||
// POST image message
|
||||
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), async (req, res) => {
|
||||
try {
|
||||
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No image' });
|
||||
|
||||
const imageUrl = `/uploads/images/${req.file.filename}`;
|
||||
const { content, replyToId } = req.body;
|
||||
|
||||
const 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);
|
||||
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// 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);
|
||||
// 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' ||
|
||||
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);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// Add/toggle reaction
|
||||
router.post('/:id/reactions', authMiddleware, (req, res) => {
|
||||
// POST reaction
|
||||
router.post('/:id/reactions', authMiddleware, async (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);
|
||||
try {
|
||||
const message = await queryOne(req.schema, 'SELECT * FROM messages WHERE id=$1 AND is_deleted=FALSE', [req.params.id]);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
|
||||
// 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);
|
||||
|
||||
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) {
|
||||
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
|
||||
await exec(req.schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
||||
} else {
|
||||
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
|
||||
await exec(req.schema, 'INSERT INTO reactions (message_id,user_id,emoji) VALUES ($1,$2,$3)', [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);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
|
||||
return router;
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
const express = require('express');
|
||||
const webpush = require('web-push');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Get or generate VAPID keys stored in settings
|
||||
function getVapidKeys() {
|
||||
const db = getDb();
|
||||
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
|
||||
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
|
||||
// VAPID keys are stored in settings; lazily initialised on first request
|
||||
let vapidPublicKey = null;
|
||||
|
||||
async function getVapidKeys(schema) {
|
||||
const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'");
|
||||
const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'");
|
||||
if (!pub?.value || !priv?.value) {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
||||
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
||||
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||
[keys.publicKey]
|
||||
);
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||
[keys.privateKey]
|
||||
);
|
||||
console.log('[Push] Generated new VAPID keys');
|
||||
return keys;
|
||||
}
|
||||
return { publicKey: pub.value, privateKey: priv.value };
|
||||
}
|
||||
|
||||
function initWebPush() {
|
||||
const keys = getVapidKeys();
|
||||
webpush.setVapidDetails(
|
||||
'mailto:admin@jama.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
async function initWebPush(schema) {
|
||||
const keys = await getVapidKeys(schema);
|
||||
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
||||
return keys.publicKey;
|
||||
}
|
||||
|
||||
// Export for use in index.js
|
||||
let vapidPublicKey = null;
|
||||
function getVapidPublicKey() {
|
||||
if (!vapidPublicKey) vapidPublicKey = initWebPush();
|
||||
return vapidPublicKey;
|
||||
}
|
||||
|
||||
// Send a push notification to all subscriptions for a user
|
||||
async function sendPushToUser(userId, payload) {
|
||||
const db = getDb();
|
||||
getVapidPublicKey(); // ensure webpush is configured
|
||||
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
// Called from index.js socket push notifications — schema comes from caller
|
||||
async function sendPushToUser(schema, userId, payload) {
|
||||
try {
|
||||
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
|
||||
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
@@ -51,54 +45,68 @@ async function sendPushToUser(userId, 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);
|
||||
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Push] sendPushToUser error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/push/vapid-public — returns VAPID public key for client subscription
|
||||
router.get('/vapid-public', (req, res) => {
|
||||
res.json({ publicKey: getVapidPublicKey() });
|
||||
router.get('/vapid-public', async (req, res) => {
|
||||
try {
|
||||
if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema);
|
||||
res.json({ publicKey: vapidPublicKey });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /api/push/subscribe — save push subscription for current user
|
||||
router.post('/subscribe', authMiddleware, (req, res) => {
|
||||
router.post('/subscribe', authMiddleware, async (req, res) => {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth)
|
||||
return res.status(400).json({ error: 'Invalid subscription' });
|
||||
}
|
||||
const db = getDb();
|
||||
try {
|
||||
const device = req.device || 'desktop';
|
||||
// Delete any existing subscription for this user+device or this endpoint, then insert fresh
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? OR (user_id = ? AND device = ?)').run(endpoint, req.user.id, device);
|
||||
db.prepare('INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?)').run(req.user.id, device, endpoint, keys.p256dh, keys.auth);
|
||||
await exec(req.schema,
|
||||
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
|
||||
[endpoint, req.user.id, device]
|
||||
);
|
||||
await exec(req.schema,
|
||||
'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)',
|
||||
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /api/push/generate-vapid — admin: generate (or regenerate) VAPID keys
|
||||
router.post('/generate-vapid', authMiddleware, (req, res) => {
|
||||
router.post('/generate-vapid', authMiddleware, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
|
||||
const db = getDb();
|
||||
try {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
||||
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
||||
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
||||
// Reinitialise webpush with new keys immediately
|
||||
await exec(req.schema,
|
||||
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||
[keys.publicKey]
|
||||
);
|
||||
await exec(req.schema,
|
||||
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
||||
[keys.privateKey]
|
||||
);
|
||||
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
||||
vapidPublicKey = keys.publicKey;
|
||||
console.log('[Push] VAPID keys regenerated by admin');
|
||||
res.json({ publicKey: keys.publicKey });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /api/push/unsubscribe — remove subscription
|
||||
router.post('/unsubscribe', authMiddleware, (req, res) => {
|
||||
router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
|
||||
try {
|
||||
await exec(req.schema,
|
||||
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
|
||||
[req.user.id, endpoint]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = { router, sendPushToUser, getVapidPublicKey };
|
||||
module.exports = { router, sendPushToUser };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
const multer = require('multer');
|
||||
const { parse: csvParse } = require('csv-parse/sync');
|
||||
@@ -8,389 +8,371 @@ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 *
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function canViewEvent(db, event, userId, isToolManager) {
|
||||
if (isToolManager) return true;
|
||||
if (event.is_public) return true;
|
||||
// Private: user must be in an assigned user group
|
||||
const assigned = db.prepare(`
|
||||
async function isToolManagerFn(schema, user) {
|
||||
if (user.role === 'admin') return true;
|
||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
|
||||
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
|
||||
if (!groupIds.length) return false;
|
||||
const ph = groupIds.map((_,i) => `$${i+2}`).join(',');
|
||||
return !!(await queryOne(schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [user.id, ...groupIds]));
|
||||
}
|
||||
|
||||
async function canViewEvent(schema, event, userId, isToolManager) {
|
||||
if (isToolManager || event.is_public) return true;
|
||||
const assigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
|
||||
WHERE eug.event_id = ? AND ugm.user_id = ?
|
||||
`).get(event.id, userId);
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, userId]);
|
||||
return !!assigned;
|
||||
}
|
||||
|
||||
function isToolManagerFn(db, user) {
|
||||
if (user.role === 'admin') return true;
|
||||
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
|
||||
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
|
||||
const groupIds = [...new Set([
|
||||
...JSON.parse(tmSetting?.value || '[]'),
|
||||
...JSON.parse(gmSetting?.value || '[]'),
|
||||
])];
|
||||
if (!groupIds.length) return false;
|
||||
return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds);
|
||||
async function enrichEvent(schema, event) {
|
||||
event.event_type = event.event_type_id
|
||||
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
|
||||
: null;
|
||||
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
|
||||
event.user_groups = await query(schema, `
|
||||
SELECT ug.id, ug.name FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id WHERE eug.event_id=$1
|
||||
`, [event.id]);
|
||||
return event;
|
||||
}
|
||||
|
||||
function enrichEvent(db, event) {
|
||||
event.event_type = event.event_type_id
|
||||
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
|
||||
: null;
|
||||
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
|
||||
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
|
||||
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
|
||||
await exec(schema, `
|
||||
UPDATE events SET
|
||||
title = COALESCE($1, title),
|
||||
event_type_id = $2,
|
||||
start_at = COALESCE($3, start_at),
|
||||
end_at = COALESCE($4, end_at),
|
||||
all_day = COALESCE($5, all_day),
|
||||
location = $6,
|
||||
description = $7,
|
||||
is_public = COALESCE($8, is_public),
|
||||
track_availability = COALESCE($9, track_availability),
|
||||
recurrence_rule = $10,
|
||||
updated_at = NOW()
|
||||
WHERE id = $11
|
||||
`, [
|
||||
title?.trim() || null,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : origEvent.event_type_id,
|
||||
startAt || null,
|
||||
endAt || null,
|
||||
allDay !== undefined ? allDay : null,
|
||||
location !== undefined ? (location || null) : origEvent.location,
|
||||
description !== undefined ? (description || null) : origEvent.description,
|
||||
isPublic !== undefined ? isPublic : null,
|
||||
trackAvailability !== undefined ? trackAvailability : null,
|
||||
recurrenceRule !== undefined ? recurrenceRule : origEvent.recurrence_rule,
|
||||
eventId,
|
||||
]);
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
await exec(schema, 'DELETE FROM event_user_groups WHERE event_id=$1', [eventId]);
|
||||
for (const ugId of userGroupIds)
|
||||
await exec(schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
}
|
||||
event.user_groups = db.prepare(`
|
||||
SELECT ug.id, ug.name FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id = eug.user_group_id
|
||||
WHERE eug.event_id = ?
|
||||
`).all(event.id);
|
||||
return event;
|
||||
}
|
||||
|
||||
// ── Event Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/event-types', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() });
|
||||
router.get('/event-types', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await query(req.schema, 'SELECT * FROM event_types ORDER BY is_default DESC, name ASC');
|
||||
res.json({ eventTypes });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
router.post('/event-types', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const db = getDb();
|
||||
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
|
||||
try {
|
||||
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [name.trim()]))
|
||||
return res.status(400).json({ error: 'Event type with that name already exists' });
|
||||
}
|
||||
const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs)
|
||||
VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0);
|
||||
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) });
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
|
||||
);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [r.rows[0].id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
|
||||
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
|
||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||
if (name && name.trim() !== et.name) {
|
||||
if (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' });
|
||||
}
|
||||
db.prepare(`UPDATE event_types SET
|
||||
name = COALESCE(?, name),
|
||||
colour = COALESCE(?, colour),
|
||||
default_user_group_id = ?,
|
||||
default_duration_hrs = COALESCE(?, default_duration_hrs)
|
||||
WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id);
|
||||
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) });
|
||||
await exec(req.schema, `
|
||||
UPDATE event_types SET
|
||||
name = COALESCE($1, name),
|
||||
colour = COALESCE($2, colour),
|
||||
default_user_group_id = $3,
|
||||
default_duration_hrs = COALESCE($4, default_duration_hrs)
|
||||
WHERE id=$5
|
||||
`, [name?.trim()||null, colour||null, defaultUserGroupId??et.default_user_group_id, defaultDurationHrs||null, et.id]);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [et.id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
|
||||
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
|
||||
// Null out event_type_id on events using this type
|
||||
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
|
||||
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
|
||||
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
|
||||
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// List events (with optional date range filter)
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const itm = isToolManagerFn(db, req.user);
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const { from, to } = req.query;
|
||||
let q = 'SELECT * FROM events WHERE 1=1';
|
||||
let sql = 'SELECT * FROM events WHERE 1=1';
|
||||
const params = [];
|
||||
if (from) { q += ' AND end_at >= ?'; params.push(from); }
|
||||
if (to) { q += ' AND start_at <= ?'; params.push(to); }
|
||||
q += ' ORDER BY start_at ASC';
|
||||
const events = db.prepare(q).all(...params)
|
||||
.filter(e => canViewEvent(db, e, req.user.id, itm))
|
||||
.map(e => {
|
||||
enrichEvent(db, e);
|
||||
// Include current user's response so the list can show the awaiting indicator
|
||||
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(e.id, req.user.id);
|
||||
let pi = 1;
|
||||
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
|
||||
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
|
||||
sql += ' ORDER BY start_at ASC';
|
||||
const rawEvents = await query(req.schema, sql, params);
|
||||
const events = [];
|
||||
for (const e of rawEvents) {
|
||||
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
|
||||
await enrichEvent(req.schema, e);
|
||||
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [e.id, req.user.id]);
|
||||
e.my_response = mine?.response || null;
|
||||
return e;
|
||||
});
|
||||
events.push(e);
|
||||
}
|
||||
res.json({ events });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get single event
|
||||
router.get('/:id', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
router.get('/me/pending', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const pending = await query(req.schema, `
|
||||
SELECT DISTINCT e.* FROM events e
|
||||
JOIN event_user_groups eug ON eug.event_id=e.id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE ugm.user_id=$1 AND e.track_availability=TRUE
|
||||
AND e.end_at >= NOW()
|
||||
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
|
||||
ORDER BY e.start_at ASC
|
||||
`, [req.user.id]);
|
||||
const result = [];
|
||||
for (const e of pending) result.push(await enrichEvent(req.schema, e));
|
||||
res.json({ events: result });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = isToolManagerFn(db, req.user);
|
||||
if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' });
|
||||
enrichEvent(db, event);
|
||||
// Availability (only for assigned group members / tool managers)
|
||||
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) {
|
||||
const responses = db.prepare(`
|
||||
SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar
|
||||
FROM event_availability ea JOIN users u ON u.id = ea.user_id
|
||||
WHERE ea.event_id = ?
|
||||
`).all(req.params.id);
|
||||
event.availability = responses;
|
||||
// Count no-response: users in assigned groups who haven't responded
|
||||
const assignedUserIds = db.prepare(`
|
||||
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 = ?
|
||||
`).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;
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// Create event
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||
const db = getDb();
|
||||
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, recurrence_rule, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
title.trim(), eventTypeId || null, startAt, endAt,
|
||||
allDay ? 1 : 0, location || null, description || null,
|
||||
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
|
||||
recurrenceRule ? JSON.stringify(recurrenceRule) : null, req.user.id
|
||||
);
|
||||
const eventId = r.lastInsertRowid;
|
||||
try {
|
||||
const r = await queryResult(req.schema, `
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
const eventId = r.rows[0].id;
|
||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId);
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
|
||||
res.json({ event: enrichEvent(db, event) });
|
||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
res.json({ event: await enrichEvent(req.schema, event) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||
db.prepare(`UPDATE events SET
|
||||
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
|
||||
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
|
||||
location = ?, description = ?, is_public = COALESCE(?, is_public),
|
||||
track_availability = COALESCE(?, track_availability),
|
||||
recurrence_rule = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`).run(
|
||||
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
|
||||
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
|
||||
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
|
||||
req.params.id
|
||||
);
|
||||
// For recurring events: if scope='future', update all future occurrences too
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||
|
||||
// Recurring future scope — update all future occurrences
|
||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||
const futureEvents = 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);
|
||||
}
|
||||
}
|
||||
const futureEvents = await query(req.schema, `
|
||||
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
||||
AND start_at >= $3 AND title=$4
|
||||
`, [req.params.id, event.created_by, event.start_at, event.title]);
|
||||
for (const fe of futureEvents)
|
||||
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
||||
}
|
||||
|
||||
// Clean up availability for users removed from groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
// Find which groups are being removed
|
||||
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')
|
||||
.all(req.params.id).map(r => r.user_group_id);
|
||||
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
|
||||
// Remove availability responses for users who are only in removed groups
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUserIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?')
|
||||
.all(removedGid).map(r => r.user_id);
|
||||
for (const uid of removedUserIds) {
|
||||
// Check if user is still in ANY remaining group for this event
|
||||
const stillAssigned = newGroupSet.size > 0 && db.prepare(`
|
||||
SELECT 1 FROM user_group_members
|
||||
WHERE user_id = ? AND user_group_id IN (${[...newGroupSet].map(()=>'?').join(',')})
|
||||
`).get(uid, ...[...newGroupSet]);
|
||||
if (!stillAssigned) {
|
||||
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?')
|
||||
.run(req.params.id, uid);
|
||||
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
|
||||
for (const uid of removedUids) {
|
||||
if (newGroupSet.size > 0) {
|
||||
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||
if (stillAssigned) continue;
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id);
|
||||
for (const ugId of userGroupIds)
|
||||
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId);
|
||||
}
|
||||
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
res.json({ event: enrichEvent(db, updated) });
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
res.json({ event: await enrichEvent(req.schema, updated) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete event
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Availability ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Submit/update availability
|
||||
router.put('/:id/availability', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' });
|
||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
||||
const { response } = req.body;
|
||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||
// User must be in an assigned group
|
||||
const inGroup = db.prepare(`
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
|
||||
WHERE eug.event_id = ? AND ugm.user_id = ?
|
||||
`).get(event.id, req.user.id);
|
||||
const itm = isToolManagerFn(db, req.user);
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')
|
||||
`).run(event.id, req.user.id, response, response);
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [event.id, req.user.id, response]);
|
||||
res.json({ success: true, response });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete availability (withdraw response)
|
||||
router.delete('/:id/availability', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
||||
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get pending availability for current user (events they need to respond to)
|
||||
router.get('/me/pending', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare(`
|
||||
SELECT e.* FROM events e
|
||||
JOIN event_user_groups eug ON eug.event_id = e.id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
|
||||
WHERE ugm.user_id = ? AND e.track_availability = 1
|
||||
AND e.end_at >= datetime('now')
|
||||
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id = e.id AND ea.user_id = ?)
|
||||
ORDER BY e.start_at ASC
|
||||
`).all(req.user.id, req.user.id);
|
||||
res.json({ events: pending.map(e => enrichEvent(db, e)) });
|
||||
});
|
||||
|
||||
// Bulk availability response
|
||||
router.post('/me/bulk-availability', authMiddleware, (req, res) => {
|
||||
const { responses } = req.body; // [{ eventId, response }]
|
||||
router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||
const { responses } = req.body;
|
||||
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`);
|
||||
try {
|
||||
let saved = 0;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
for (const { eventId, response } of responses) {
|
||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event || !event.track_availability) continue;
|
||||
const inGroup = db.prepare(`SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id WHERE eug.event_id = ? AND ugm.user_id = ?`).get(eventId, req.user.id);
|
||||
const itm = isToolManagerFn(db, req.user);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [eventId, req.user.id]);
|
||||
if (!inGroup && !itm) continue;
|
||||
stmt.run(eventId, req.user.id, response, response);
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [eventId, req.user.id, response]);
|
||||
saved++;
|
||||
}
|
||||
res.json({ success: true, saved });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── CSV Bulk Import ───────────────────────────────────────────────────────────
|
||||
// ── CSV Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => {
|
||||
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true });
|
||||
const db = getDb();
|
||||
const results = rows.map((row, i) => {
|
||||
const rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
|
||||
const results = await Promise.all(rows.map(async (row, i) => {
|
||||
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
|
||||
const startDate = row['start_date'] || row['Start Date'] || '';
|
||||
const startTime = row['start_time'] || row['Start Time'] || '09:00';
|
||||
const location = row['event_location'] || row['location'] || '';
|
||||
const typeName = row['event_type'] || row['Event Type'] || 'Default';
|
||||
const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1;
|
||||
|
||||
if (!title || !startDate) return { row: i + 1, title, error: 'Missing title or start date', duplicate: false };
|
||||
|
||||
if (!title || !startDate) return { row:i+1, title, error:'Missing title or start date', duplicate:false };
|
||||
const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
|
||||
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
|
||||
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
|
||||
|
||||
// Check duplicate
|
||||
const dup = db.prepare('SELECT id, title FROM events WHERE title = ? AND start_at = ?').get(title, startAt);
|
||||
|
||||
return { row: i+1, title, startAt, endAt, location, typeName, durHrs, duplicate: !!dup, duplicateId: dup?.id, error: null };
|
||||
});
|
||||
const dup = await queryOne(req.schema, 'SELECT id,title FROM events WHERE title=$1 AND start_at=$2', [title, startAt]);
|
||||
return { row:i+1, title, startAt, endAt, location, typeName, durHrs, duplicate:!!dup, duplicateId:dup?.id, error:null };
|
||||
}));
|
||||
res.json({ rows: results });
|
||||
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
|
||||
});
|
||||
|
||||
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const { rows } = req.body; // filtered rows from preview (client excludes skipped)
|
||||
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { rows } = req.body;
|
||||
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
|
||||
const db = getDb();
|
||||
try {
|
||||
let imported = 0;
|
||||
const stmt = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, location, is_public, track_availability, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, 1, 0, ?)`);
|
||||
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
||||
for (const row of rows) {
|
||||
if (row.error || row.skip) continue;
|
||||
let typeId = null;
|
||||
if (row.typeName) {
|
||||
let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName);
|
||||
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
|
||||
if (!et) {
|
||||
// Create missing type with random colour
|
||||
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
||||
const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour);
|
||||
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
|
||||
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
|
||||
const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour);
|
||||
typeId = r2.lastInsertRowid;
|
||||
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
|
||||
typeId = cr.rows[0].id;
|
||||
} else { typeId = et.id; }
|
||||
}
|
||||
stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id);
|
||||
await exec(req.schema,
|
||||
'INSERT INTO events (title,event_type_id,start_at,end_at,location,is_public,track_availability,created_by) VALUES ($1,$2,$3,$4,$5,TRUE,FALSE,$6)',
|
||||
[row.title, typeId, row.startAt, row.endAt, row.location||null, req.user.id]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
res.json({ success: true, imported });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,187 +4,145 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { query, queryOne, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Generic icon storage factory
|
||||
function makeIconStorage(prefix) {
|
||||
return multer.diskStorage({
|
||||
destination: '/app/uploads/logos',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${prefix}_${Date.now()}${ext}`);
|
||||
}
|
||||
filename: (req, file, cb) => cb(null, `${prefix}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
}
|
||||
|
||||
const iconUploadOpts = {
|
||||
const iconOpts = {
|
||||
limits: { fileSize: 1 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
};
|
||||
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconOpts });
|
||||
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconOpts });
|
||||
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconOpts });
|
||||
|
||||
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
|
||||
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
|
||||
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
|
||||
// Helper: upsert a setting
|
||||
async function setSetting(schema, key, value) {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2, updated_at=NOW()",
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
// Get public settings (accessible by all)
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const settings = db.prepare('SELECT key, value FROM settings').all();
|
||||
// GET /api/settings
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT key, value FROM settings');
|
||||
const obj = {};
|
||||
for (const s of settings) obj[s.key] = s.value;
|
||||
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
||||
for (const r of rows) obj[r.key] = r.value;
|
||||
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
||||
if (admin) obj.admin_email = admin.email;
|
||||
// Expose app version from Docker build arg env var
|
||||
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
|
||||
obj.app_version = process.env.JAMA_VERSION || 'dev';
|
||||
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||
res.json({ settings: obj });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update app name (admin)
|
||||
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
|
||||
router.patch('/app-name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
|
||||
try {
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [name.trim()]);
|
||||
res.json({ success: true, name: name.trim() });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
|
||||
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
|
||||
const logoUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const srcPath = req.file.path;
|
||||
|
||||
try {
|
||||
// Generate PWA icons from the uploaded logo
|
||||
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
|
||||
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
|
||||
|
||||
await sharp(srcPath)
|
||||
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(icon192Path);
|
||||
|
||||
await sharp(srcPath)
|
||||
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(icon512Path);
|
||||
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
|
||||
// Store the PWA icon paths so the manifest can reference them
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
|
||||
|
||||
await sharp(req.file.path).resize(192,192,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-192.png');
|
||||
await sharp(req.file.path).resize(512,512,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-512.png');
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
|
||||
await setSetting(req.schema, 'pwa_icon_192', '/uploads/logos/pwa-icon-192.png');
|
||||
await setSetting(req.schema, 'pwa_icon_512', '/uploads/logos/pwa-icon-512.png');
|
||||
res.json({ logoUrl });
|
||||
} catch (err) {
|
||||
console.error('[Logo] Failed to generate PWA icons:', err.message);
|
||||
// Still save the logo even if icon generation fails
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
|
||||
console.error('[Logo] icon gen failed:', err.message);
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
|
||||
res.json({ logoUrl });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload New Chat icon (admin)
|
||||
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
|
||||
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run(iconUrl, iconUrl);
|
||||
res.json({ iconUrl });
|
||||
try { await setSetting(req.schema, 'icon_newchat', iconUrl); res.json({ iconUrl }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload Group Info icon (admin)
|
||||
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
|
||||
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run(iconUrl, iconUrl);
|
||||
res.json({ iconUrl });
|
||||
try { await setSetting(req.schema, 'icon_groupinfo', iconUrl); res.json({ iconUrl }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Reset all settings to defaults (admin)
|
||||
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
|
||||
router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
|
||||
const db = getDb();
|
||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
||||
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
|
||||
if (colorTitleDark !== undefined) upd.run('color_title_dark', colorTitleDark || '', colorTitleDark || '');
|
||||
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
|
||||
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
|
||||
try {
|
||||
if (colorTitle !== undefined) await setSetting(req.schema, 'color_title', colorTitle || '');
|
||||
if (colorTitleDark !== undefined) await setSetting(req.schema, 'color_title_dark', colorTitleDark || '');
|
||||
if (colorAvatarPublic !== undefined) await setSetting(req.schema, 'color_avatar_public', colorAvatarPublic || '');
|
||||
if (colorAvatarDm !== undefined) await setSetting(req.schema, 'color_avatar_dm', colorAvatarDm || '');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const originalName = process.env.APP_NAME || 'jama';
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
|
||||
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
|
||||
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512', 'color_title', 'color_title_dark', 'color_avatar_public', 'color_avatar_dm')").run();
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
|
||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
|
||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Registration code ─────────────────────────────────────────────────────────
|
||||
// Valid codes — in production these would be stored/validated server-side
|
||||
const VALID_CODES = {
|
||||
// JAMA-Team: full access — chat, branding, group manager, schedule manager
|
||||
'JAMA-TEAM-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
|
||||
// JAMA-Brand: chat + branding only
|
||||
'JAMA-BRAND-2024': { appType: 'JAMA-Brand', branding: true, groupManager: false, scheduleManager: false },
|
||||
// Legacy codes — map to new tiers
|
||||
'JAMA-FULL-2024': { appType: 'JAMA-Team', branding: true, groupManager: true, scheduleManager: true },
|
||||
'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
|
||||
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||
};
|
||||
|
||||
router.post('/register', authMiddleware, adminMiddleware, (req, res) => {
|
||||
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { code } = req.body;
|
||||
const db = getDb();
|
||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
||||
|
||||
try {
|
||||
if (!code?.trim()) {
|
||||
// Clear registration
|
||||
upd.run('registration_code', '', '');
|
||||
upd.run('app_type', 'JAMA-Chat', 'JAMA-Chat');
|
||||
upd.run('feature_branding', 'false', 'false');
|
||||
upd.run('feature_group_manager', 'false', 'false');
|
||||
upd.run('feature_schedule_manager', 'false', 'false');
|
||||
return res.json({ success: true, features: { branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat' } });
|
||||
await setSetting(req.schema, 'registration_code', '');
|
||||
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
|
||||
await setSetting(req.schema, 'feature_branding', 'false');
|
||||
await setSetting(req.schema, 'feature_group_manager', 'false');
|
||||
await setSetting(req.schema, 'feature_schedule_manager', 'false');
|
||||
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
|
||||
}
|
||||
|
||||
const match = VALID_CODES[code.trim().toUpperCase()];
|
||||
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
|
||||
|
||||
upd.run('registration_code', code.trim(), code.trim());
|
||||
upd.run('app_type', match.appType || 'JAMA-Chat', match.appType || 'JAMA-Chat');
|
||||
upd.run('feature_branding', match.branding ? 'true' : 'false', match.branding ? 'true' : 'false');
|
||||
upd.run('feature_group_manager', match.groupManager ? 'true' : 'false', match.groupManager ? 'true' : 'false');
|
||||
upd.run('feature_schedule_manager', match.scheduleManager ? 'true' : 'false', match.scheduleManager ? 'true' : 'false');
|
||||
|
||||
res.json({ success: true, features: { branding: match.branding, groupManager: match.groupManager, scheduleManager: match.scheduleManager, appType: match.appType } });
|
||||
await setSetting(req.schema, 'registration_code', code.trim());
|
||||
await setSetting(req.schema, 'app_type', match.appType);
|
||||
await setSetting(req.schema, 'feature_branding', match.branding ? 'true' : 'false');
|
||||
await setSetting(req.schema, 'feature_group_manager', match.groupManager ? 'true' : 'false');
|
||||
await setSetting(req.schema, 'feature_schedule_manager', match.scheduleManager ? 'true' : 'false');
|
||||
res.json({ success:true, features:{ branding:match.branding, groupManager:match.groupManager, scheduleManager:match.scheduleManager, appType:match.appType } });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Save team management group assignments
|
||||
router.patch('/team', authMiddleware, adminMiddleware, (req, res) => {
|
||||
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { toolManagers } = req.body;
|
||||
const db = getDb();
|
||||
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
|
||||
try {
|
||||
if (toolManagers !== undefined) {
|
||||
const val = JSON.stringify(toolManagers || []);
|
||||
upd.run('team_tool_managers', val, val);
|
||||
// Keep legacy keys in sync so existing teamManagerMiddleware still works
|
||||
upd.run('team_group_managers', val, val);
|
||||
upd.run('team_schedule_managers', val, val);
|
||||
await setSetting(req.schema, 'team_tool_managers', val);
|
||||
await setSetting(req.schema, 'team_group_managers', val);
|
||||
await setSetting(req.schema, 'team_schedule_managers', val);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,302 +1,313 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
|
||||
module.exports = function(io) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function postSysMsg(db, groupId, actorId, content) {
|
||||
const r = db.prepare(`INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system')`).run(groupId, actorId, content);
|
||||
const msg = db.prepare(`
|
||||
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||
`).get(r.lastInsertRowid);
|
||||
async function postSysMsg(schema, groupId, actorId, content) {
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[groupId, actorId, content]
|
||||
);
|
||||
const msg = await queryOne(schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
|
||||
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
|
||||
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm
|
||||
FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1
|
||||
`, [r.rows[0].id]);
|
||||
if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); }
|
||||
}
|
||||
|
||||
// Add user silently — no system message (used during initial creation)
|
||||
function addUserSilent(db, dmGroupId, userId) {
|
||||
db.prepare("INSERT OR IGNORE INTO group_members (group_id, user_id, joined_at) VALUES (?, ?, datetime('now'))").run(dmGroupId, userId);
|
||||
async function addUserSilent(schema, dmGroupId, userId) {
|
||||
await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]);
|
||||
io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`);
|
||||
const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId);
|
||||
const dmGroup = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
|
||||
if (dmGroup) io.to(`user:${userId}`).emit('group:new', { group: dmGroup });
|
||||
}
|
||||
|
||||
// Add user with system message (used when editing existing group)
|
||||
function addUser(db, dmGroupId, userId, actorId) {
|
||||
addUserSilent(db, dmGroupId, userId);
|
||||
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
||||
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has joined the conversation.`);
|
||||
async function addUser(schema, dmGroupId, userId, actorId) {
|
||||
await addUserSilent(schema, dmGroupId, userId);
|
||||
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
}
|
||||
|
||||
// Remove user with system message
|
||||
function removeUser(db, dmGroupId, userId, actorId) {
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(dmGroupId, userId);
|
||||
async function removeUser(schema, dmGroupId, userId, actorId) {
|
||||
await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]);
|
||||
io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
|
||||
io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
|
||||
const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
||||
postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`);
|
||||
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
}
|
||||
|
||||
function getUserIdsForGroup(db, userGroupId) {
|
||||
return db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(userGroupId).map(r => r.user_id);
|
||||
async function getUserIdsForGroup(schema, userGroupId) {
|
||||
const rows = await query(schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [userGroupId]);
|
||||
return rows.map(r => r.user_id);
|
||||
}
|
||||
|
||||
// ── Current user's group memberships (no admin required) ────────────────────────
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const groupIds = db.prepare('SELECT user_group_id FROM user_group_members WHERE user_id = ?').all(req.user.id).map(r => r.user_group_id);
|
||||
res.json({ groupIds });
|
||||
// GET /me — current user's user-group memberships
|
||||
router.get('/me', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.user.id]);
|
||||
const groupIds = rows.map(r => r.user_group_id);
|
||||
if (groupIds.length === 0) return res.json({ userGroups: [] });
|
||||
const placeholders = groupIds.map((_,i) => `$${i+1}`).join(',');
|
||||
const userGroups = await query(req.schema, `SELECT * FROM user_groups WHERE id IN (${placeholders}) ORDER BY name ASC`, groupIds);
|
||||
// Also resolve multi-group DMs this user can see
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
|
||||
FROM multi_group_dms mgd
|
||||
JOIN multi_group_dm_members mgdm ON mgdm.multi_group_dm_id=mgd.id
|
||||
WHERE mgdm.user_group_id IN (${placeholders})
|
||||
GROUP BY mgd.id ORDER BY mgd.name ASC
|
||||
`, groupIds);
|
||||
for (const dm of mgDms) {
|
||||
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
|
||||
}
|
||||
res.json({ userGroups, multiGroupDms: mgDms });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── MULTI-GROUP DMs — must come before /:id ───────────────────────────────────
|
||||
|
||||
router.get('/multigroup', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const dms = db.prepare(`
|
||||
SELECT mgd.*,
|
||||
(SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id = mgd.id) as group_count
|
||||
// GET /multigroup
|
||||
router.get('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const dms = await query(req.schema, `
|
||||
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
|
||||
FROM multi_group_dms mgd ORDER BY mgd.name ASC
|
||||
`).all();
|
||||
`);
|
||||
for (const dm of dms) {
|
||||
dm.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(dm.id).map(r => r.user_group_id);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
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.` });
|
||||
}
|
||||
}
|
||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
||||
const dmResult = db.prepare(`INSERT INTO groups (name, type, owner_id, is_managed) VALUES (?, 'private', ?, 1)`).run(name.trim(), admin?.id || req.user.id);
|
||||
const dmGroupId = dmResult.lastInsertRowid;
|
||||
const mgResult = db.prepare(`INSERT INTO multi_group_dms (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId);
|
||||
const mgId = mgResult.lastInsertRowid;
|
||||
|
||||
const validGroupIds = userGroupIds.map(Number).filter(Boolean);
|
||||
const addedUsers = new Set();
|
||||
for (const ugId of validGroupIds) {
|
||||
db.prepare('INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)').run(mgId, ugId);
|
||||
for (const uid of getUserIdsForGroup(db, ugId)) {
|
||||
if (!addedUsers.has(uid)) { addedUsers.add(uid); addUserSilent(db, dmGroupId, uid); }
|
||||
}
|
||||
}
|
||||
|
||||
const dm = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(mgId);
|
||||
dm.memberGroupIds = validGroupIds;
|
||||
dm.group_count = validGroupIds.length;
|
||||
res.json({ dm });
|
||||
});
|
||||
|
||||
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
|
||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||
// POST /multigroup
|
||||
router.post('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, userGroupIds } = req.body;
|
||||
|
||||
if (name && name.trim() !== mg.name) {
|
||||
if (db.prepare('SELECT id FROM multi_group_dms WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), mg.id)) {
|
||||
return res.status(400).json({ error: 'Name already in use' });
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
if (!Array.isArray(userGroupIds) || userGroupIds.length < 2) return res.status(400).json({ error: 'At least 2 groups required' });
|
||||
try {
|
||||
// Check for existing DM with same groups
|
||||
const existing = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||
if (existing) {
|
||||
const existingIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [existing.id])).map(r => r.user_group_id).sort();
|
||||
const newIds = [...userGroupIds].map(Number).sort();
|
||||
if (JSON.stringify(existingIds) === JSON.stringify(newIds)) return res.status(400).json({ error: 'A DM with these groups already exists' });
|
||||
}
|
||||
db.prepare("UPDATE multi_group_dms SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.id);
|
||||
if (mg.dm_group_id) db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), mg.dm_group_id);
|
||||
// Create the chat group
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed,is_multi_group) VALUES ($1,'private',FALSE,TRUE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
// Create multi_group_dms record
|
||||
const mgr = await queryResult(req.schema,
|
||||
'INSERT INTO multi_group_dms (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||
[name.trim(), dmGroupId]
|
||||
);
|
||||
const mgId = mgr.rows[0].id;
|
||||
// Add each user group and their members
|
||||
const addedUsers = new Set();
|
||||
for (const ugId of userGroupIds) {
|
||||
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mgId, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) {
|
||||
if (!addedUsers.has(uid)) {
|
||||
addedUsers.add(uid);
|
||||
await addUserSilent(req.schema, dmGroupId, uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dmGroup = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
|
||||
res.json({ dm: { id: mgId, name: name.trim(), dm_group_id: dmGroupId, group_count: userGroupIds.length }, group: dmGroup });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
if (Array.isArray(userGroupIds) && mg.dm_group_id) {
|
||||
const newGroupIds = new Set(userGroupIds.map(Number).filter(Boolean));
|
||||
const currentGroupIds = new Set(db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id));
|
||||
|
||||
for (const ugId of newGroupIds) {
|
||||
// PATCH /multigroup/:id
|
||||
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { userGroupIds } = req.body;
|
||||
try {
|
||||
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
|
||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||
if (!Array.isArray(userGroupIds)) return res.status(400).json({ error: 'userGroupIds required' });
|
||||
const currentGroupIds = new Set((await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [mg.id])).map(r => r.user_group_id));
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
for (const ugId of newGroupSet) {
|
||||
if (!currentGroupIds.has(ugId)) {
|
||||
db.prepare("INSERT OR IGNORE INTO multi_group_dm_members (multi_group_dm_id, user_group_id) VALUES (?, ?)").run(mg.id, ugId);
|
||||
// Add users silently — no per-user notifications in multi-group DMs
|
||||
for (const uid of getUserIdsForGroup(db, ugId)) addUserSilent(db, mg.dm_group_id, uid);
|
||||
const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId);
|
||||
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been added to this conversation.`);
|
||||
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mg.id, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) await addUserSilent(req.schema, mg.dm_group_id, uid);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A new group has joined this conversation.`);
|
||||
}
|
||||
}
|
||||
for (const ugId of currentGroupIds) {
|
||||
if (!newGroupIds.has(ugId)) {
|
||||
db.prepare('DELETE FROM multi_group_dm_members WHERE multi_group_dm_id = ? AND user_group_id = ?').run(mg.id, ugId);
|
||||
// Remove users silently — no per-user notifications in multi-group DMs
|
||||
for (const uid of getUserIdsForGroup(db, ugId)) {
|
||||
const stillIn = db.prepare('SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id = mgdm.user_group_id WHERE mgdm.multi_group_dm_id = ? AND ugm.user_id = ?').get(mg.id, uid);
|
||||
if (!newGroupSet.has(ugId)) {
|
||||
await exec(req.schema, 'DELETE FROM multi_group_dm_members WHERE multi_group_dm_id=$1 AND user_group_id=$2', [mg.id, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) {
|
||||
const stillIn = await queryOne(req.schema, `
|
||||
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
|
||||
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
|
||||
`, [mg.id, uid]);
|
||||
if (!stillIn) {
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
|
||||
io.in(`user:${uid}`).socketsLeave(`group:${mg.dm_group_id}`);
|
||||
io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
}
|
||||
const ug = db.prepare('SELECT name FROM user_groups WHERE id = ?').get(ugId);
|
||||
if (ug) postSysMsg(db, mg.dm_group_id, req.user.id, `Group "${ug.name}" has been removed from this conversation.`);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
|
||||
updated.memberGroupIds = db.prepare('SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id = ?').all(mg.id).map(r => r.user_group_id);
|
||||
updated.group_count = updated.memberGroupIds.length;
|
||||
res.json({ dm: updated });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const mg = db.prepare('SELECT * FROM multi_group_dms WHERE id = ?').get(req.params.id);
|
||||
// DELETE /multigroup/:id
|
||||
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
|
||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||
if (mg.dm_group_id) {
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(mg.dm_group_id).map(r => r.user_id);
|
||||
db.prepare('DELETE FROM groups WHERE id = ?').run(mg.dm_group_id);
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
|
||||
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
db.prepare('DELETE FROM multi_group_dms WHERE id = ?').run(mg.id);
|
||||
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── USER GROUPS ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const groups = db.prepare(`
|
||||
SELECT ug.*,
|
||||
(SELECT COUNT(*) FROM user_group_members WHERE user_group_id = ug.id) as member_count
|
||||
// GET / — list all user groups
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await query(req.schema, `
|
||||
SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id=ug.id) AS member_count
|
||||
FROM user_groups ug ORDER BY ug.name ASC
|
||||
`).all();
|
||||
`);
|
||||
res.json({ groups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
||||
// GET /:id
|
||||
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
|
||||
FROM user_group_members ugm JOIN users u ON u.id = ugm.user_id
|
||||
WHERE ugm.user_group_id = ? ORDER BY u.name ASC
|
||||
`).all(req.params.id);
|
||||
const members = await query(req.schema, `
|
||||
SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status
|
||||
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
||||
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
||||
`, [req.params.id]);
|
||||
res.json({ group, members });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
// POST / — create user group
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds = [] } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const db = getDb();
|
||||
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
|
||||
return res.status(400).json({ error: 'A group with that name already exists' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||
if (existing) return res.status(400).json({ error: 'Name already in use' });
|
||||
// Create the managed DM group
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
const ugr = await queryResult(req.schema,
|
||||
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||
[name.trim(), dmGroupId]
|
||||
);
|
||||
const ugId = ugr.rows[0].id;
|
||||
for (const uid of memberIds) {
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ugId, uid]);
|
||||
await addUserSilent(req.schema, dmGroupId, uid);
|
||||
}
|
||||
// Check for duplicate member set
|
||||
const newIds = [...new Set((Array.isArray(memberIds) ? memberIds : []).map(Number).filter(Boolean))].sort();
|
||||
if (newIds.length > 0) {
|
||||
const allGroups = db.prepare('SELECT id, name FROM user_groups').all();
|
||||
for (const existing of allGroups) {
|
||||
const existingIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(existing.id).map(r => r.user_id).sort();
|
||||
if (existingIds.length === newIds.length && existingIds.every((id, i) => id === newIds[i])) {
|
||||
return res.status(400).json({ error: `Group not created — "${existing.name}" already exists with the same members.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
||||
const dmResult = db.prepare(`INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, is_managed) VALUES (?, 'private', ?, 0, 0, 1)`).run(name.trim(), admin?.id || req.user.id);
|
||||
const dmGroupId = dmResult.lastInsertRowid;
|
||||
const ugResult = db.prepare(`INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)`).run(name.trim(), dmGroupId);
|
||||
const ugId = ugResult.lastInsertRowid;
|
||||
|
||||
for (const uid of (Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : [])) {
|
||||
db.prepare("INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)").run(ugId, uid);
|
||||
addUserSilent(db, dmGroupId, uid);
|
||||
}
|
||||
const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(ugId);
|
||||
res.json({ group });
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
|
||||
res.json({ userGroup: ug });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
// PATCH /:id
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds } = req.body;
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (name && name.trim() !== ug.name) {
|
||||
if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id)) {
|
||||
return res.status(400).json({ error: 'Name already in use' });
|
||||
}
|
||||
db.prepare("UPDATE user_groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.id);
|
||||
if (ug.dm_group_id) db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.dm_group_id);
|
||||
const conflict = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), ug.id]);
|
||||
if (conflict) return res.status(400).json({ error: 'Name already in use' });
|
||||
await exec(req.schema, 'UPDATE user_groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.id]);
|
||||
if (ug.dm_group_id) await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.dm_group_id]);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
const currentSet = new Set((await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.user_id));
|
||||
const addedUids = [], removedUids = [];
|
||||
|
||||
for (const uid of newIds) {
|
||||
if (!currentSet.has(uid)) {
|
||||
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);
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
|
||||
await addUser(req.schema, ug.dm_group_id, uid, req.user.id);
|
||||
addedUids.push(uid);
|
||||
}
|
||||
}
|
||||
for (const uid of currentSet) {
|
||||
if (!newIds.has(uid)) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// For multi-group DMs: add/remove users silently, post group-level notification once
|
||||
const mgDms = db.prepare('SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm JOIN multi_group_dms mgd ON mgd.id = mgdm.multi_group_dm_id WHERE mgdm.user_group_id = ?').all(ug.id);
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
for (const uid of addedUids) addUserSilent(db, mg.dm_group_id, uid);
|
||||
for (const uid of addedUids) await addUserSilent(req.schema, mg.dm_group_id, uid);
|
||||
for (const uid of removedUids) {
|
||||
const stillInMg = db.prepare('SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id = mgdm.user_group_id WHERE mgdm.multi_group_dm_id = ? AND ugm.user_id = ?').get(mg.id, uid);
|
||||
if (!stillInMg) {
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(mg.dm_group_id, uid);
|
||||
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) 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.`);
|
||||
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 = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
res.json({ group: updated });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
|
||||
// DELETE /:id
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
if (ug.dm_group_id) {
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(ug.dm_group_id).map(r => r.user_id);
|
||||
db.prepare('DELETE FROM groups WHERE id = ?').run(ug.dm_group_id);
|
||||
for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
|
||||
for (const uid of members) { io.in(`user:${uid}`).socketsLeave(`group:${ug.dm_group_id}`); io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id }); }
|
||||
}
|
||||
db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id);
|
||||
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -3,148 +3,116 @@ const bcrypt = require('bcryptjs');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
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 avatarStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/avatars',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
|
||||
}
|
||||
filename: (req, file, cb) => cb(null, `avatar_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadAvatar = multer({
|
||||
storage: avatarStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
|
||||
function resolveUniqueName(db, baseName, excludeId = null) {
|
||||
const existing = db.prepare(
|
||||
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
|
||||
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
|
||||
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
const existing = await query(schema,
|
||||
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||
[excludeId ?? -1, baseName, `${baseName} (%)`]
|
||||
);
|
||||
if (existing.length === 0) return baseName;
|
||||
let max = 0;
|
||||
for (const u of existing) {
|
||||
const m = u.name.match(/\((\d+)\)$/);
|
||||
if (m) max = Math.max(max, parseInt(m[1]));
|
||||
else max = Math.max(max, 0);
|
||||
}
|
||||
for (const u of existing) { const m = u.name.match(/\((\d+)\)$/); if (m) max = Math.max(max, parseInt(m[1])); else max = Math.max(max, 0); }
|
||||
return `${baseName} (${max + 1})`;
|
||||
}
|
||||
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||
|
||||
function getDefaultPassword(db) {
|
||||
return process.env.USER_PASS || 'user@1234';
|
||||
}
|
||||
|
||||
// List users (admin)
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online
|
||||
FROM users WHERE status != 'deleted'
|
||||
ORDER BY created_at ASC
|
||||
`).all();
|
||||
// List users
|
||||
router.get('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
"SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Search users (public-ish for mentions/add-member)
|
||||
router.get('/search', authMiddleware, (req, res) => {
|
||||
// Search users
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
const { q, groupId } = req.query;
|
||||
const db = getDb();
|
||||
try {
|
||||
let users;
|
||||
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)) {
|
||||
// Private group or direct message — only show members of this group
|
||||
users = db.prepare(`
|
||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
|
||||
FROM users u
|
||||
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
|
||||
WHERE u.status = 'active' AND u.id != ?
|
||||
AND (u.name LIKE ? OR u.display_name LIKE ?)
|
||||
LIMIT 10
|
||||
`).all(parseInt(groupId), req.user.id, `%${q}%`, `%${q}%`);
|
||||
users = await query(req.schema,
|
||||
"SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
|
||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||
);
|
||||
} else {
|
||||
// Public group — all active users
|
||||
users = db.prepare(`
|
||||
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
|
||||
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
|
||||
LIMIT 10
|
||||
`).all(req.user.id, `%${q}%`, `%${q}%`);
|
||||
users = await query(req.schema,
|
||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) LIMIT 10",
|
||||
[req.user.id, `%${q}%`]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
users = db.prepare(`
|
||||
SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
|
||||
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
|
||||
LIMIT 10
|
||||
`).all(`%${q}%`, `%${q}%`);
|
||||
users = await query(req.schema,
|
||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) LIMIT 10",
|
||||
[`%${q}%`]
|
||||
);
|
||||
}
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Check if a display name is already taken (excludes self)
|
||||
router.get('/check-display-name', authMiddleware, (req, res) => {
|
||||
// Check display name
|
||||
router.get('/check-display-name', authMiddleware, async (req, res) => {
|
||||
const { name } = req.query;
|
||||
if (!name) return res.json({ taken: false });
|
||||
const db = getDb();
|
||||
const conflict = db.prepare(
|
||||
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
||||
).get(name, req.user.id);
|
||||
try {
|
||||
const conflict = await queryOne(req.schema,
|
||||
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||
[name, req.user.id]
|
||||
);
|
||||
res.json({ taken: !!conflict });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
|
||||
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Create user
|
||||
router.post('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { name, email, password, role } = req.body;
|
||||
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
|
||||
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
|
||||
|
||||
const db = getDb();
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
try {
|
||||
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||
|
||||
const resolvedName = resolveUniqueName(db, name.trim());
|
||||
const pw = (password || '').trim() || getDefaultPassword(db);
|
||||
const resolvedName = await resolveUniqueName(req.schema, name.trim());
|
||||
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||
VALUES (?, ?, ?, ?, 'active', 1)
|
||||
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
|
||||
|
||||
addUserToPublicGroups(result.lastInsertRowid);
|
||||
// Admin users are automatically added to the Support group
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
await addUserToPublicGroups(req.schema, userId);
|
||||
if (role === 'admin') {
|
||||
const supportGroupId = getOrCreateSupportGroup();
|
||||
if (supportGroupId) {
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid);
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
}
|
||||
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Bulk create users
|
||||
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Bulk create
|
||||
router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { users } = req.body;
|
||||
const db = getDb();
|
||||
const results = { created: [], skipped: [] };
|
||||
const seenEmails = new Set();
|
||||
const defaultPw = getDefaultPassword(db);
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||
VALUES (?, ?, ?, ?, 'active', 1)
|
||||
`);
|
||||
|
||||
const defaultPw = process.env.USER_PASS || 'user@1234';
|
||||
try {
|
||||
for (const u of users) {
|
||||
const email = (u.email || '').trim().toLowerCase();
|
||||
const name = (u.name || '').trim();
|
||||
@@ -152,167 +120,145 @@ router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
||||
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
||||
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||
seenEmails.add(email);
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
const exists = await queryOne(req.schema, 'SELECT id FROM users WHERE email=$1', [email]);
|
||||
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
||||
try {
|
||||
const resolvedName = resolveUniqueName(db, name);
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (u.password || '').trim() || defaultPw;
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const newRole = u.role === 'admin' ? 'admin' : 'member';
|
||||
const r = insertUser.run(resolvedName, email, hash, newRole);
|
||||
addUserToPublicGroups(r.lastInsertRowid);
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||
[resolvedName, email, hash, newRole]
|
||||
);
|
||||
await addUserToPublicGroups(req.schema, r.rows[0].id);
|
||||
if (newRole === 'admin') {
|
||||
const supportGroupId = getOrCreateSupportGroup();
|
||||
if (supportGroupId) {
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
|
||||
}
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, r.rows[0].id]);
|
||||
}
|
||||
results.created.push(email);
|
||||
} catch (e) {
|
||||
results.skipped.push({ email, reason: e.message });
|
||||
} catch (e) { results.skipped.push({ email, reason: e.message }); }
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user name (admin only — req 5)
|
||||
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Patch name
|
||||
router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
// Pass the target's own id so their current name is excluded from the duplicate check
|
||||
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
|
||||
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
|
||||
const resolvedName = await resolveUniqueName(req.schema, name.trim(), req.params.id);
|
||||
await exec(req.schema, 'UPDATE users SET name=$1, updated_at=NOW() WHERE id=$2', [resolvedName, target.id]);
|
||||
res.json({ success: true, name: resolvedName });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user role (admin)
|
||||
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Patch role
|
||||
router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { role } = req.body;
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
||||
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
|
||||
// If promoted to admin, ensure they're in the Support group
|
||||
await exec(req.schema, 'UPDATE users SET role=$1, updated_at=NOW() WHERE id=$2', [role, target.id]);
|
||||
if (role === 'admin') {
|
||||
const supportGroupId = getOrCreateSupportGroup();
|
||||
if (supportGroupId) {
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
|
||||
}
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, target.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Reset user password (admin)
|
||||
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Reset password
|
||||
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
|
||||
const db = getDb();
|
||||
try {
|
||||
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 });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Suspend user (admin)
|
||||
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
||||
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||
// Suspend / activate / delete
|
||||
router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
||||
await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
router.patch('/:id/activate', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, "UPDATE users SET status='active', updated_at=NOW() WHERE id=$1", [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
||||
await exec(req.schema, "UPDATE users SET status='deleted', updated_at=NOW() WHERE id=$1", [t.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Activate user (admin)
|
||||
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Delete user (admin)
|
||||
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
||||
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Update own profile — display name must be unique (req 6)
|
||||
router.patch('/me/profile', authMiddleware, (req, res) => {
|
||||
// Update own profile
|
||||
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
||||
const db = getDb();
|
||||
try {
|
||||
if (displayName) {
|
||||
const conflict = db.prepare(
|
||||
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
||||
).get(displayName, req.user.id);
|
||||
const conflict = await queryOne(req.schema,
|
||||
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||
[displayName, req.user.id]
|
||||
);
|
||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||
}
|
||||
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id);
|
||||
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id);
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload avatar — resize if needed, skip compression for files under 500 KB
|
||||
// Upload avatar
|
||||
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const filePath = req.file.path;
|
||||
const fileSizeBytes = req.file.size;
|
||||
const FIVE_HUNDRED_KB = 500 * 1024;
|
||||
const MAX_DIM = 256; // max width/height in pixels
|
||||
|
||||
const MAX_DIM = 256;
|
||||
const image = sharp(filePath);
|
||||
const meta = await image.metadata();
|
||||
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
|
||||
|
||||
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
|
||||
// Small enough and already correctly sized — serve as-is
|
||||
} else {
|
||||
// Resize (and compress only if over 500 KB)
|
||||
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
|
||||
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
|
||||
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
|
||||
// Compress: use webp for best size/quality ratio
|
||||
pipeline = pipeline.webp({ quality: 82 });
|
||||
await pipeline.toFile(outPath + '.webp');
|
||||
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
|
||||
if (req.file.size >= 500 * 1024 || needsResize) {
|
||||
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
|
||||
const fs = require('fs');
|
||||
fs.unlinkSync(filePath);
|
||||
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);
|
||||
const avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||
return res.json({ avatarUrl });
|
||||
} 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 db = getDb();
|
||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||
res.json({ avatarUrl });
|
||||
} catch (err) {
|
||||
console.error('Avatar processing error:', err);
|
||||
// Fall back to serving unprocessed file
|
||||
console.error('Avatar error:', err);
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]).catch(()=>{});
|
||||
res.json({ avatarUrl });
|
||||
}
|
||||
});
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.9.87}"
|
||||
VERSION="${1:-0.10.1}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
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:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- APP_TYPE=${APP_TYPE:-selfhost}
|
||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||
- USER_PASS=${USER_PASS:-user@1234}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||
- DB_KEY=${DB_KEY}
|
||||
- APP_NAME=${APP_NAME:-jama}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=${DB_NAME:-jama}
|
||||
- DB_USER=${DB_USER:-jama}
|
||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
- HOST_DOMAIN=${HOST_DOMAIN:-}
|
||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
|
||||
volumes:
|
||||
- jama_db:/app/data
|
||||
- jama_uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: ${PROJECT_NAME:-jama}_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME:-jama}
|
||||
- POSTGRES_USER=${DB_USER:-jama}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- jama_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
jama_db:
|
||||
driver: local
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.87",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToastProvider } from './contexts/ToastContext.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Chat from './pages/Chat.jsx';
|
||||
import ChangePassword from './pages/ChangePassword.jsx';
|
||||
import HostAdmin from './pages/HostAdmin.jsx';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading, mustChangePassword } = useAuth();
|
||||
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }) {
|
||||
|
||||
function AuthRoute({ children }) {
|
||||
const { user, loading, mustChangePassword } = useAuth();
|
||||
// Always show login in light mode regardless of user's saved theme preference
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
if (loading) return null;
|
||||
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
||||
@@ -28,7 +28,6 @@ function AuthRoute({ children }) {
|
||||
}
|
||||
|
||||
function RestoreTheme() {
|
||||
// Called when entering a protected route — restore the user's saved theme
|
||||
const saved = localStorage.getItem('jama-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
return null;
|
||||
@@ -38,6 +37,12 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
{/* /host renders outside AuthProvider — has its own key-based auth */}
|
||||
<Route path="/host" element={<HostAdmin />} />
|
||||
<Route path="/host/*" element={<HostAdmin />} />
|
||||
{/* All other routes go through jama auth */}
|
||||
<Route path="/*" element={
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Routes>
|
||||
@@ -48,6 +53,8 @@ export default function App() {
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
|
||||
|
||||
{/* User section */}
|
||||
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
|
||||
{item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
|
||||
{features.scheduleManager && item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
|
||||
|
||||
{/* Admin section */}
|
||||
{isAdmin && (
|
||||
|
||||
@@ -221,8 +221,13 @@ export default function UserManagerModal({ onClose }) {
|
||||
const fileRef = useRef(null);
|
||||
const [userPass, setUserPass] = useState('user@1234');
|
||||
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const load = () => {
|
||||
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||
setLoadError('');
|
||||
api.getUsers()
|
||||
.then(({ users }) => setUsers(users))
|
||||
.catch(e => setLoadError(e.message || 'Failed to load users'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -305,6 +310,11 @@ export default function UserManagerModal({ onClose }) {
|
||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{loading ? (
|
||||
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||
) : loadError ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--error)' }}>
|
||||
<div style={{ marginBottom: 10 }}>⚠ {loadError}</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setLoading(true); load(); }}>Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{filtered.map(u => (
|
||||
|
||||
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