v0.12.0 codes for FCM and rebranded jama to RosterChirp

This commit is contained in:
2026-03-22 20:15:57 -04:00
parent 21dc788cd3
commit 819d60d693
40 changed files with 426 additions and 363 deletions

View File

@@ -28,10 +28,10 @@ router.get('/', (req, res) => {
const about = {
...DEFAULTS,
...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
version: process.env.ROSTERCHIRP_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
// Always expose original app identity — not overrideable via about.json or settings
default_app_name: 'jama',
default_logo: '/icons/jama.png',
default_app_name: 'rosterchirp',
default_logo: '/icons/rosterchirp.png',
};
// Never expose docker_image — removed from UI

View File

@@ -1,5 +1,5 @@
/**
* routes/host.js — JAMA-HOST control plane
* routes/host.js — RosterChirp-Host control plane
*
* All routes require the HOST_ADMIN_KEY header.
* These routes operate on the 'public' schema (tenant registry).
@@ -141,7 +141,7 @@ router.post('/tenants', async (req, res) => {
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';
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-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'");
@@ -161,7 +161,7 @@ router.post('/tenants', async (req, res) => {
// 7. Reload domain cache
await reloadTenantCache();
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`;
@@ -220,7 +220,7 @@ router.patch('/tenants/:slug', async (req, res) => {
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';
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
}
@@ -310,7 +310,7 @@ 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';
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
res.json({
ok: true,
appType: process.env.APP_TYPE || 'selfhost',

View File

@@ -1,50 +1,62 @@
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const express = require('express');
const router = express.Router();
const { query, queryOne, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// VAPID keys are stored in settings; lazily initialised on first request
let vapidPublicKey = null;
// ── Firebase Admin ─────────────────────────────────────────────────────────────
let firebaseAdmin = null;
let firebaseApp = 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();
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 };
}
async function initWebPush(schema) {
const keys = await getVapidKeys(schema);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
return keys.publicKey;
}
// Called from index.js socket push notifications — schema comes from caller
async function sendPushToUser(schema, userId, payload) {
function getMessaging() {
if (firebaseApp) return firebaseAdmin.messaging(firebaseApp);
const json = process.env.FIREBASE_SERVICE_ACCOUNT;
if (!json) return null;
try {
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
firebaseAdmin = require('firebase-admin');
const svc = JSON.parse(json);
firebaseApp = firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.cert(svc),
});
console.log('[Push] Firebase Admin initialised');
return firebaseAdmin.messaging(firebaseApp);
} catch (e) {
console.error('[Push] Firebase Admin init failed:', e.message);
return null;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
// Called from index.js socket push notifications
async function sendPushToUser(schema, userId, payload) {
const messaging = getMessaging();
if (!messaging) return;
try {
const subs = await query(schema,
'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL',
[userId]
);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
await messaging.send({
token: sub.fcm_token,
data: {
title: payload.title || 'New Message',
body: payload.body || '',
url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '',
},
android: { priority: 'high' },
webpush: { headers: { Urgency: 'high' } },
});
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Remove stale tokens
const stale = [
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
];
if (stale.includes(err.code)) {
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
}
}
@@ -54,56 +66,47 @@ async function sendPushToUser(schema, userId, payload) {
}
}
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 }); }
// ── Routes ─────────────────────────────────────────────────────────────────────
// Public — frontend fetches this to initialise the Firebase JS SDK
router.get('/firebase-config', (req, res) => {
const apiKey = process.env.FIREBASE_API_KEY;
const projectId = process.env.FIREBASE_PROJECT_ID;
const messagingSenderId = process.env.FIREBASE_MESSAGING_SENDER_ID;
const appId = process.env.FIREBASE_APP_ID;
const vapidKey = process.env.FIREBASE_VAPID_KEY;
if (!apiKey || !projectId || !messagingSenderId || !appId) {
return res.status(503).json({ error: 'FCM not configured' });
}
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
});
// Register / refresh an FCM token for the logged-in user
router.post('/subscribe', authMiddleware, async (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth)
return res.status(400).json({ error: 'Invalid subscription' });
const { fcmToken } = req.body;
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
try {
const device = req.device || 'desktop';
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
[endpoint, req.user.id, device]
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
[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]
'INSERT INTO push_subscriptions (user_id, device, fcm_token) VALUES ($1, $2, $3)',
[req.user.id, device, fcmToken]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/generate-vapid', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
try {
const keys = webpush.generateVAPIDKeys();
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.publicKey]
);
await exec(req.schema,
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
[keys.privateKey]
);
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
vapidPublicKey = keys.publicKey;
res.json({ publicKey: keys.publicKey });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Remove the FCM token for the logged-in user / device
router.post('/unsubscribe', authMiddleware, async (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
try {
const device = req.device || 'desktop';
await exec(req.schema,
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
[req.user.id, endpoint]
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
[req.user.id, device]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }

View File

@@ -37,7 +37,7 @@ router.get('/', async (req, res) => {
for (const r of rows) obj[r.key] = r.value;
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
if (admin) obj.admin_email = admin.email;
obj.app_version = process.env.JAMA_VERSION || 'dev';
obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
// Tell the frontend whether this request came from the HOST_DOMAIN.
// Used to show/hide the Control Panel menu item — only visible on the host's own domain.
@@ -105,7 +105,7 @@ router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
try {
const originalName = process.env.APP_NAME || 'jama';
const originalName = process.env.APP_NAME || 'rosterchirp';
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')");
@@ -114,9 +114,9 @@ router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
});
const VALID_CODES = {
'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 },
'ROSTERCHIRP-TEAM-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
'ROSTERCHIRP-BRAND-2024': { appType:'RosterChirp-Brand', branding:true, groupManager:false, scheduleManager:false },
'ROSTERCHIRP-FULL-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
};
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
@@ -124,11 +124,11 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (!code?.trim()) {
await setSetting(req.schema, 'registration_code', '');
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
await setSetting(req.schema, 'app_type', 'RosterChirp-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'} });
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'RosterChirp-Chat'} });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });