Okay Wish I could say I know exactly. Will do better next time promise lol

This commit is contained in:
2026-02-26 17:47:04 -07:00
parent 2fa50b001c
commit 9e51aac570
727 changed files with 217309 additions and 5801 deletions

View File

@@ -4,7 +4,9 @@ import { request as httpRequest } from 'http';
import { logger } from '../utils/logger';
const execAsync = promisify(exec);
const DOCKER_SOCKET = '/var/run/docker.sock';
/** Docker socket proxy URL (tecnativa/docker-socket-proxy) — read-only container inspection */
const DOCKER_PROXY_URL = process.env.DOCKER_PROXY_URL || 'http://docker-socket-proxy:2375';
interface DockerContainerStatus {
running: boolean;
@@ -14,21 +16,20 @@ interface DockerContainerStatus {
}
/**
* Make a request to the Docker Engine API over Unix socket.
* Make a request to the Docker Engine API via the socket proxy (HTTP).
*/
function dockerRequest(
method: string,
path: string,
body?: unknown,
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const url = new URL(path, DOCKER_PROXY_URL);
const options = {
socketPath: DOCKER_SOCKET,
path,
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: body
? { 'Content-Type': 'application/json' }
: undefined,
};
const req = httpRequest(options, (res) => {
@@ -42,17 +43,16 @@ function dockerRequest(
});
});
req.setTimeout(5000, () => {
req.destroy(new Error('Docker proxy request timed out'));
});
req.on('error', reject);
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
/**
* Check if a container is running via Docker API.
* Check if a container is running via Docker socket proxy.
*/
async function getContainerStatus(containerName: string): Promise<DockerContainerStatus> {
try {

View File

@@ -1002,6 +1002,78 @@ class EmailService {
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendVolunteerShiftThankYou(options: {
volunteerEmail: string;
volunteerName: string;
shiftTitle: string;
shiftDate: string;
shiftTime: string;
shiftLocation: string;
signupUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
SHIFT_TIME: options.shiftTime,
SHIFT_LOCATION: options.shiftLocation,
SIGNUP_URL: options.signupUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-shift-thank-you');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('volunteer-shift-thank-you', 'html');
const txtTemplate = this.loadTemplate('volunteer-shift-thank-you', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Thank you for volunteering — ${options.shiftTitle}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendVolunteerReengagement(options: {
volunteerEmail: string;
volunteerName: string;
lastActivityDate: string;
lastActivityType: string;
signupUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
LAST_ACTIVITY_DATE: options.lastActivityDate,
LAST_ACTIVITY_TYPE: options.lastActivityType,
SIGNUP_URL: options.signupUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-reengagement');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('volunteer-reengagement', 'html');
const txtTemplate = this.loadTemplate('volunteer-reengagement', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `We miss you — ${orgName}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendResponseVerification(options: {
recipientEmail: string;
campaignTitle: string;
@@ -1047,6 +1119,42 @@ class EmailService {
logger.warn('Using response verification template from filesystem (fallback)');
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendProfileLinkEmail(options: {
recipientEmail: string;
recipientName: string;
profileUrl: string;
organizationName: string;
extraNotes?: string;
}): Promise<SendEmailResult> {
const vars: Record<string, string> = {
RECIPIENT_NAME: options.recipientName,
PROFILE_URL: options.profileUrl,
ORGANIZATION_NAME: options.organizationName,
EXTRA_NOTES: options.extraNotes || '',
};
const dbTemplate = await this.loadTemplateFromDatabase('profile-link');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('profile-link', 'html');
const txtTemplate = this.loadTemplate('profile-link', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Your Profile — ${options.organizationName}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,

View File

@@ -0,0 +1,437 @@
import { gancioClient } from './gancio.client';
import { siteSettingsService } from '../modules/settings/settings.service';
import { env } from '../config/env';
import { logger } from '../utils/logger';
// CML settings fields that trigger a Gancio sync when changed
const GANCIO_RELEVANT_FIELDS = [
'organizationName',
'organizationShortName',
'publicColorPrimary',
'publicColorBgBase',
'publicColorBgContainer',
'publicHeaderGradient',
'footerText',
'enableEvents',
// Feature flags that affect the injected nav bar
'enableInfluence',
'enableMap',
'enableMediaFeatures',
'enablePayments',
'navConfig',
] as const;
interface GancioColorPalette {
primary?: string;
error?: string;
info?: string;
success?: string;
warning?: string;
}
/**
* Generates custom CSS for Gancio to match CML's dark theme.
* Targets Vuetify component classes used in Gancio's UI.
*/
function buildCustomCss(settings: {
publicColorBgBase?: string | null;
publicColorBgContainer?: string | null;
publicColorPrimary?: string | null;
publicHeaderGradient?: string | null;
}): string {
const lines: string[] = ['/* Auto-synced from Changemaker Lite */'];
if (settings.publicColorBgBase) {
lines.push(`.v-application { background-color: ${settings.publicColorBgBase} !important; }`);
lines.push(`.v-main { background-color: ${settings.publicColorBgBase} !important; }`);
}
if (settings.publicColorBgContainer) {
lines.push(`.v-card { background-color: ${settings.publicColorBgContainer} !important; }`);
lines.push(`.v-dialog .v-card { background-color: ${settings.publicColorBgContainer} !important; }`);
}
if (settings.publicHeaderGradient) {
lines.push(`.v-app-bar { background: ${settings.publicHeaderGradient} !important; }`);
} else if (settings.publicColorPrimary) {
lines.push(`.v-app-bar { background-color: ${settings.publicColorPrimary} !important; }`);
}
// Push Gancio content down to make room for the injected CML nav bar (CSS fallback)
lines.push(`.v-application--wrap, .v-application .v-main { padding-top: 56px !important; }`);
return lines.join('\n');
}
/**
* Compact inline SVG icons matching Ant Design's outlined style.
* Stroke-based, 24x24 viewBox, rendered at 1em (14px in the nav context).
* Using fill="none" stroke="currentColor" so they inherit link color + hover transitions.
*/
const NAV_ICONS: Record<string, string> = {
Home: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
Campaigns: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
Map: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>',
Shifts: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
Events: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
Gallery: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>',
Donate: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
Admin: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
Website: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
Docs: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
};
/**
* Generates a self-contained JS snippet that injects a CML navigation bar
* into Gancio's page. Uses Gancio's `custom_js` setting.
*
* The bar mirrors PublicLayout's header: same gradient, colors, nav links,
* inline SVG icons, and feature-flag-aware conditional rendering.
*/
interface NavConfigItem {
id: string;
label: string;
path: string;
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom';
featureFlag?: string;
external?: boolean;
}
/** Map navConfig icon IDs to the SVG NAV_ICONS keys */
const ICON_ID_TO_KEY: Record<string, string> = {
HomeOutlined: 'Home',
SendOutlined: 'Campaigns',
EnvironmentOutlined: 'Map',
CalendarOutlined: 'Shifts',
PlayCircleOutlined: 'Gallery',
HeartOutlined: 'Donate',
DollarOutlined: 'Donate',
ShoppingOutlined: 'Donate',
GlobalOutlined: 'Website',
BookOutlined: 'Docs',
};
function buildCustomJs(settings: {
organizationName?: string | null;
publicHeaderGradient?: string | null;
publicColorPrimary?: string | null;
enableInfluence?: boolean | null;
enableMap?: boolean | null;
enableMediaFeatures?: boolean | null;
enablePayments?: boolean | null;
navConfig?: { items: NavConfigItem[] } | null;
}, appUrl: string, homeUrl: string): string {
const orgName = settings.organizationName || 'Changemaker Lite';
const gradient = settings.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
// Strip trailing slash from URLs for consistent link construction
const baseUrl = appUrl.replace(/\/+$/, '');
const home = homeUrl.replace(/\/+$/, '');
// Build nav links array from navConfig if available, else fall back to feature flags
const links: Array<{ href: string; label: string; icon: string; active?: boolean }> = [];
if (settings.navConfig?.items) {
const featureFlagMap: Record<string, boolean | null | undefined> = {
enableInfluence: settings.enableInfluence,
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
};
const sorted = [...settings.navConfig.items]
.filter(item => item.enabled)
.filter(item => !item.featureFlag || featureFlagMap[item.featureFlag] !== false)
.sort((a, b) => a.order - b.order);
for (const item of sorted) {
// Resolve $token paths first
if (item.path === '$landing') {
links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Website' });
} else if (item.path === '$docs') {
links.push({ href: `${home}/docs/`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Docs' });
} else if (item.id === 'events') {
// Events is the current page on Gancio, mark active
links.push({ href: '#', label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Events', active: true });
} else if (item.external && item.id === 'home') {
links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Home' });
} else if (item.external) {
links.push({ href: item.path, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label });
} else {
links.push({ href: `${baseUrl}${item.path}`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label });
}
}
} else {
// Legacy fallback: build from feature flags
links.push({ href: home, label: 'Home', icon: 'Home' });
if (settings.enableInfluence !== false) {
links.push({ href: `${baseUrl}/campaigns`, label: 'Campaigns', icon: 'Campaigns' });
}
if (settings.enableMap !== false) {
links.push({ href: `${baseUrl}/map`, label: 'Map', icon: 'Map' });
links.push({ href: `${baseUrl}/shifts`, label: 'Shifts', icon: 'Shifts' });
}
links.push({ href: '#', label: 'Events', icon: 'Events', active: true });
if (settings.enableMediaFeatures !== false) {
links.push({ href: `${baseUrl}/gallery`, label: 'Gallery', icon: 'Gallery' });
}
if (settings.enablePayments === true) {
links.push({ href: `${baseUrl}/donate`, label: 'Donate', icon: 'Donate' });
}
}
// Always add Admin link — the admin page handles its own auth guard
links.push({ href: `${baseUrl}/app`, label: 'Admin', icon: 'Admin' });
// Serialize links for embedding in JS (icon key is a string, resolved at runtime)
const linksJson = JSON.stringify(links);
// Build the icons object for the generated JS (keyed by label)
const iconsUsed = [...new Set(links.map((l) => l.icon))];
const iconsObj = iconsUsed
.map((key) => `${key}:'${NAV_ICONS[key]?.replace(/'/g, "\\'") ?? ''}'`)
.join(',');
// Safe-escaped org name for embedding in JS string literals
const orgNameEscaped = orgName.replace(/'/g, "\\'").replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `(function(){
if(document.getElementById('cml-nav-bar'))return;
var icons={${iconsObj}};
var bar=document.createElement('div');
bar.id='cml-nav-bar';
bar.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:${gradient};font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;box-sizing:border-box;';
var brand=document.createElement('a');
brand.href='${baseUrl}/campaigns';
brand.style.cssText='color:#fff;text-decoration:none;font-size:18px;font-weight:600;display:flex;align-items:center;gap:8px;white-space:nowrap;';
brand.textContent='${orgNameEscaped}';
bar.appendChild(brand);
var nav=document.createElement('div');
nav.className='cml-nav-links';
nav.style.cssText='display:flex;align-items:center;gap:16px;';
var links=${linksJson};
links.forEach(function(l){
var a=document.createElement('a');
a.href=l.href;
a.style.cssText='color:'+(l.active?'#fff':'rgba(255,255,255,0.85)')+';text-decoration:none;font-size:14px;font-weight:'+(l.active?'600':'400')+';border-bottom:'+(l.active?'2px solid #fff':'2px solid transparent')+';padding-bottom:2px;transition:color 0.2s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;';
a.innerHTML=(icons[l.icon]||'')+'<span>'+l.label+'</span>';
if(!l.active){
a.addEventListener('mouseenter',function(){this.style.color='#fff';});
a.addEventListener('mouseleave',function(){this.style.color='rgba(255,255,255,0.85)';});
}
nav.appendChild(a);
});
bar.appendChild(nav);
var mobileBack=document.createElement('a');
mobileBack.href='${baseUrl}/campaigns';
mobileBack.className='cml-nav-mobile-back';
mobileBack.style.cssText='display:none;color:#fff;text-decoration:none;font-size:14px;white-space:nowrap;';
mobileBack.textContent='\\u2190 Back to ${orgNameEscaped}';
bar.appendChild(mobileBack);
var style=document.createElement('style');
style.textContent='.v-application--wrap,.v-application .v-main{padding-top:56px!important;}@media(max-width:768px){.cml-nav-links{display:none!important;}.cml-nav-mobile-back{display:block!important;}}';
document.head.appendChild(style);
document.body.insertBefore(bar,document.body.firstChild);
})();`;
}
class GancioSettingsSyncService {
private syncInProgress = false;
/**
* Quick check: does the update payload contain any Gancio-relevant fields?
*/
hasGancioChanges(payload: Record<string, unknown>): boolean {
return GANCIO_RELEVANT_FIELDS.some((f) => f in payload);
}
/**
* Full sync: reads CML settings, reads existing Gancio settings (to preserve
* non-primary colors), then pushes all mapped settings to Gancio.
* Used at API startup.
*/
async syncAll(): Promise<void> {
if (!gancioClient.enabled) return;
if (this.syncInProgress) {
logger.debug('Gancio settings sync already in progress, skipping');
return;
}
this.syncInProgress = true;
try {
const settings = await siteSettingsService.get();
// Guard: check DB-level feature flag
if (!settings.enableEvents) {
logger.debug('Gancio sync skipped: enableEvents is false');
return;
}
// Read existing Gancio settings to preserve non-primary color values
const gancioSettings = await gancioClient.getSettings();
const promises: Promise<boolean>[] = [];
// Title & description
if (settings.organizationName) {
promises.push(gancioClient.setSetting('title', settings.organizationName));
promises.push(gancioClient.setSetting('description', `${settings.organizationName} Events`));
}
// Dark theme
promises.push(gancioClient.setSetting('theme.is_dark', true));
// Color palettes — read-then-merge to preserve error/info/success/warning
if (settings.publicColorPrimary) {
const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {};
const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {};
promises.push(gancioClient.setSetting('dark_colors', {
...existingDark,
primary: settings.publicColorPrimary,
}));
promises.push(gancioClient.setSetting('light_colors', {
...existingLight,
primary: settings.publicColorPrimary,
}));
}
// Footer links
if (settings.footerText) {
const adminUrl = env.ADMIN_URL;
promises.push(gancioClient.setSetting('footerLinks', [
{ label: settings.footerText, href: adminUrl },
]));
}
// Custom CSS for deeper theming
const css = buildCustomCss(settings);
promises.push(gancioClient.setSetting('custom_css', css));
// Custom JS — inject CML navigation bar into Gancio
const appUrl = env.ADMIN_URL;
const homeUrl = `https://${env.DOMAIN}`;
const js = buildCustomJs(settings as any, appUrl, homeUrl);
promises.push(gancioClient.setSetting('custom_js', js));
const results = await Promise.allSettled(promises);
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failed = results.length - succeeded;
if (failed > 0) {
logger.warn(`Gancio settings sync: ${succeeded} succeeded, ${failed} failed`);
} else {
logger.info(`Gancio settings sync: synced ${succeeded} settings`);
}
} catch (err) {
logger.warn('Gancio settings sync failed:', err instanceof Error ? err.message : err);
} finally {
this.syncInProgress = false;
}
}
/**
* Partial sync: only pushes Gancio settings for fields that actually changed.
* Used from the PUT /api/settings handler.
*/
async syncChanged(changedFields: Record<string, unknown>): Promise<void> {
if (!gancioClient.enabled) return;
try {
// If enableEvents was just turned off, skip sync
if (changedFields.enableEvents === false) {
logger.debug('Gancio sync skipped: enableEvents set to false');
return;
}
// If enableEvents was just turned on, do a full sync
if (changedFields.enableEvents === true) {
await this.syncAll();
return;
}
// Check DB-level feature flag (may not be in changedFields)
const settings = await siteSettingsService.get();
if (!settings.enableEvents) {
logger.debug('Gancio sync skipped: enableEvents is false');
return;
}
const promises: Promise<boolean>[] = [];
// Organization name → title + description
if ('organizationName' in changedFields && changedFields.organizationName) {
const name = changedFields.organizationName as string;
promises.push(gancioClient.setSetting('title', name));
promises.push(gancioClient.setSetting('description', `${name} Events`));
}
// Primary color → dark_colors + light_colors (read-then-merge)
if ('publicColorPrimary' in changedFields && changedFields.publicColorPrimary) {
const gancioSettings = await gancioClient.getSettings();
const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {};
const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {};
promises.push(gancioClient.setSetting('dark_colors', {
...existingDark,
primary: changedFields.publicColorPrimary,
}));
promises.push(gancioClient.setSetting('light_colors', {
...existingLight,
primary: changedFields.publicColorPrimary,
}));
}
// Footer text → footerLinks
if ('footerText' in changedFields) {
const adminUrl = env.ADMIN_URL;
if (changedFields.footerText) {
promises.push(gancioClient.setSetting('footerLinks', [
{ label: changedFields.footerText as string, href: adminUrl },
]));
} else {
promises.push(gancioClient.setSetting('footerLinks', []));
}
}
// Theme-related CSS fields — rebuild CSS from full settings (not just changed fields)
const cssFields = ['publicColorBgBase', 'publicColorBgContainer', 'publicHeaderGradient', 'publicColorPrimary'];
if (cssFields.some((f) => f in changedFields)) {
const css = buildCustomCss(settings);
promises.push(gancioClient.setSetting('custom_css', css));
}
// Nav bar JS — rebuild when org name, feature flags, header gradient, or navConfig change
const jsFields = [
'organizationName', 'publicHeaderGradient', 'publicColorPrimary',
'enableInfluence', 'enableMap', 'enableMediaFeatures', 'enablePayments',
'navConfig',
];
if (jsFields.some((f) => f in changedFields)) {
const appUrl = env.ADMIN_URL;
const homeUrl = `https://${env.DOMAIN}`;
const js = buildCustomJs(settings as any, appUrl, homeUrl);
promises.push(gancioClient.setSetting('custom_js', js));
}
if (promises.length === 0) return;
const results = await Promise.allSettled(promises);
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failed = results.length - succeeded;
if (failed > 0) {
logger.warn(`Gancio partial sync: ${succeeded}/${results.length} succeeded`);
} else {
logger.debug(`Gancio partial sync: ${succeeded} settings updated`);
}
} catch (err) {
logger.warn('Gancio partial sync failed:', err instanceof Error ? err.message : err);
}
}
}
export const gancioSettingsSyncService = new GancioSettingsSyncService();

View File

@@ -148,33 +148,34 @@ class GancioClient {
/**
* Create a Gancio event from a shift
*/
async createEvent(shift: {
async createEvent(event: {
title: string;
description?: string | null;
location?: string | null;
date: Date;
startTime: string;
endTime: string;
tags?: string[];
}): Promise<number | null> {
if (!this.enabled) return null;
try {
const startDatetime = this.buildTimestamp(shift.date, shift.startTime);
const endDatetime = this.buildTimestamp(shift.date, shift.endTime);
const placeName = shift.location || 'TBD';
const startDatetime = this.buildTimestamp(event.date, event.startTime);
const endDatetime = this.buildTimestamp(event.date, event.endTime);
const placeName = event.location || 'TBD';
const event = await this.request<GancioEvent>('POST', '/api/event', {
title: shift.title,
description: shift.description || '',
const created = await this.request<GancioEvent>('POST', '/api/event', {
title: event.title,
description: event.description || '',
place_name: placeName,
place_address: shift.location || placeName,
place_address: event.location || placeName,
start_datetime: startDatetime,
end_datetime: endDatetime,
tags: ['volunteer', 'shift'],
tags: event.tags ?? ['volunteer', 'shift'],
});
logger.info(`Gancio: created event ${event.id} for shift "${shift.title}"`);
return event.id;
logger.info(`Gancio: created event ${created.id} for "${event.title}"`);
return created.id;
} catch (err) {
logger.warn('Gancio createEvent failed:', err instanceof Error ? err.message : err);
return null;
@@ -230,6 +231,63 @@ class GancioClient {
}
}
// --- Public Fetch (no auth needed) ---
/**
* Fetch events from Gancio's public API (no auth required).
* Returns raw Gancio events array, or empty array on failure.
*/
async fetchPublicEvents(): Promise<GancioEvent[]> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/api/events`, {
signal: controller.signal,
});
if (!res.ok) return [];
return await res.json() as GancioEvent[];
} finally {
clearTimeout(timeout);
}
} catch {
return [];
}
}
// --- Settings ---
/**
* Get all Gancio instance settings (flat JSON object).
* Returns null on failure (fire-and-forget safe).
*/
async getSettings(): Promise<Record<string, unknown> | null> {
if (!this.enabled) return null;
try {
return await this.request<Record<string, unknown>>('GET', '/api/settings');
} catch (err) {
logger.warn('Gancio getSettings failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* Set a single Gancio setting. Gancio's API only accepts one key/value per call.
* Returns true on success, false on failure.
*/
async setSetting(key: string, value: unknown): Promise<boolean> {
if (!this.enabled) return false;
try {
await this.request('POST', '/api/settings', { key, value });
return true;
} catch (err) {
logger.warn(`Gancio setSetting("${key}") failed:`, err instanceof Error ? err.message : err);
return false;
}
}
// --- Helpers ---
/**

View File

@@ -0,0 +1,604 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { prisma } from '../config/database';
import { decrypt } from '../utils/crypto';
// --- Types ---
export interface GiteaIssue {
id: number;
number: number;
title: string;
body: string;
state: string;
labels: GiteaLabel[];
created_at: string;
updated_at: string;
}
export interface GiteaComment {
id: number;
body: string;
user: GiteaUser;
created_at: string;
updated_at: string;
}
export interface GiteaUser {
id: number;
login: string;
full_name: string;
avatar_url: string;
email: string;
}
export interface GiteaLabel {
id: number;
name: string;
color: string;
}
interface GiteaOAuthTokenResponse {
access_token: string;
token_type: string;
refresh_token?: string;
expires_in?: number;
}
interface GiteaRepo {
id: number;
name: string;
full_name: string;
}
/** Resolved config from DB settings with env var fallback */
interface GiteaCommentsConfig {
enabled: boolean;
apiToken: string;
repoOwner: string;
repoName: string;
oauthClientId: string;
oauthClientSecret: string;
}
// --- Client ---
class GiteaClient {
// Cache resolved config for 60s to avoid DB hit on every API call
private configCache: GiteaCommentsConfig | null = null;
private configCacheExpiry = 0;
private get baseUrl(): string {
return env.GITEA_URL;
}
/**
* Load Gitea comments config from DB settings (with env var fallback).
* Cached for 60 seconds in memory.
*/
async getConfig(): Promise<GiteaCommentsConfig> {
if (this.configCache && Date.now() < this.configCacheExpiry) {
return this.configCache;
}
let dbEnabled = false;
let dbApiToken = '';
let dbRepoOwner = '';
let dbRepoName = '';
let dbOauthClientId = '';
let dbOauthClientSecret = '';
try {
const settings = await prisma.siteSettings.findFirst({
select: {
enableDocsComments: true,
giteaApiToken: true,
giteaCommentsRepoOwner: true,
giteaCommentsRepoName: true,
giteaOauthClientId: true,
giteaOauthClientSecret: true,
},
});
if (settings) {
dbEnabled = settings.enableDocsComments;
dbApiToken = this.tryDecrypt(settings.giteaApiToken);
dbRepoOwner = settings.giteaCommentsRepoOwner;
dbRepoName = settings.giteaCommentsRepoName;
dbOauthClientId = settings.giteaOauthClientId;
dbOauthClientSecret = this.tryDecrypt(settings.giteaOauthClientSecret);
}
} catch (err) {
logger.debug('Gitea config: DB lookup failed, using env fallback:', err instanceof Error ? err.message : err);
}
// DB settings take priority; env vars are fallback
const config: GiteaCommentsConfig = {
enabled: dbEnabled || env.GITEA_COMMENTS_ENABLED === 'true',
apiToken: dbApiToken || env.GITEA_API_TOKEN,
repoOwner: dbRepoOwner || env.GITEA_COMMENTS_REPO_OWNER,
repoName: dbRepoName || env.GITEA_COMMENTS_REPO_NAME || 'docs-comments',
oauthClientId: dbOauthClientId || env.GITEA_OAUTH_CLIENT_ID,
oauthClientSecret: dbOauthClientSecret || env.GITEA_OAUTH_CLIENT_SECRET,
};
this.configCache = config;
this.configCacheExpiry = Date.now() + 60_000;
return config;
}
/** Invalidate the cached config (call after settings update) */
clearConfigCache(): void {
this.configCache = null;
this.configCacheExpiry = 0;
}
/** Try to decrypt a value; if it's not encrypted (plain text), return as-is */
private tryDecrypt(value: string): string {
if (!value) return '';
try {
return decrypt(value);
} catch {
return value; // Not encrypted (e.g. during transition)
}
}
/**
* Make an authenticated request to Gitea API using the service account token
*/
private async request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
userToken?: string,
): Promise<T> {
const config = await this.getConfig();
const url = `${this.baseUrl}/api/v1${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const headers: Record<string, string> = {
Authorization: `token ${userToken || config.apiToken}`,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, {
method,
headers,
body: fetchBody,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gitea API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Health ---
async isAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/api/v1/version`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
// --- Config-aware helpers (async) ---
async isEnabled(): Promise<boolean> {
const config = await this.getConfig();
return config.enabled && !!config.apiToken && !!config.repoOwner;
}
async isOAuthEnabled(): Promise<boolean> {
const config = await this.getConfig();
return config.enabled && !!config.apiToken && !!config.repoOwner
&& !!config.oauthClientId && !!config.oauthClientSecret;
}
private async getRepoPath(): Promise<string> {
const config = await this.getConfig();
return `${config.repoOwner}/${config.repoName}`;
}
// --- Issue Management ---
/**
* Find an issue by exact title match in the comments repo
*/
async findIssueByTitle(title: string): Promise<GiteaIssue | null> {
if (!(await this.isEnabled())) return null;
try {
const repoPath = await this.getRepoPath();
const issues = await this.request<GiteaIssue[]>(
'GET',
`/repos/${repoPath}/issues?type=issues&state=open&q=${encodeURIComponent(title)}&limit=10`,
);
// Filter for exact title match (search is fuzzy)
return issues.find((i) => i.title === title) || null;
} catch (err) {
logger.warn('Gitea findIssueByTitle failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* Create a new issue for a docs page
*/
async createIssue(title: string, body: string): Promise<GiteaIssue | null> {
if (!(await this.isEnabled())) return null;
try {
const repoPath = await this.getRepoPath();
const issue = await this.request<GiteaIssue>(
'POST',
`/repos/${repoPath}/issues`,
{ title, body },
);
logger.info(`Gitea: created issue #${issue.number} for "${title}"`);
return issue;
} catch (err) {
logger.warn('Gitea createIssue failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* List all comments on an issue
*/
async listIssueComments(issueNumber: number): Promise<GiteaComment[]> {
if (!(await this.isEnabled())) return [];
try {
const repoPath = await this.getRepoPath();
return await this.request<GiteaComment[]>(
'GET',
`/repos/${repoPath}/issues/${issueNumber}/comments?limit=50`,
);
} catch (err) {
logger.warn(`Gitea listIssueComments(#${issueNumber}) failed:`, err instanceof Error ? err.message : err);
return [];
}
}
/**
* Create a comment on an issue.
* If userToken is provided, posts as that user. Otherwise uses service account.
*/
async createIssueComment(
issueNumber: number,
body: string,
userToken?: string,
): Promise<GiteaComment | null> {
if (!(await this.isEnabled())) return null;
try {
const repoPath = await this.getRepoPath();
const comment = await this.request<GiteaComment>(
'POST',
`/repos/${repoPath}/issues/${issueNumber}/comments`,
{ body },
userToken,
);
return comment;
} catch (err) {
logger.warn(`Gitea createIssueComment(#${issueNumber}) failed:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Delete a comment from an issue (used for rejected anonymous comments)
*/
async deleteIssueComment(commentId: number): Promise<boolean> {
if (!(await this.isEnabled())) return false;
try {
const repoPath = await this.getRepoPath();
await this.request(
'DELETE',
`/repos/${repoPath}/issues/comments/${commentId}`,
);
return true;
} catch (err) {
logger.warn(`Gitea deleteIssueComment(${commentId}) failed:`, err instanceof Error ? err.message : err);
return false;
}
}
// --- OAuth2 ---
/**
* Exchange an OAuth2 authorization code for an access token
*/
async exchangeOAuthCode(code: string, redirectUri: string): Promise<GiteaOAuthTokenResponse | null> {
if (!(await this.isOAuthEnabled())) return null;
const config = await this.getConfig();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${this.baseUrl}/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: config.oauthClientId,
client_secret: config.oauthClientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${res.status}): ${text}`);
}
return (await res.json()) as GiteaOAuthTokenResponse;
} catch (err) {
logger.warn('Gitea OAuth code exchange failed:', err instanceof Error ? err.message : err);
return null;
} finally {
clearTimeout(timeout);
}
}
/**
* Get the authenticated user's profile using their OAuth token
*/
async getAuthenticatedUser(userToken: string): Promise<GiteaUser | null> {
try {
return await this.request<GiteaUser>('GET', '/user', undefined, userToken);
} catch (err) {
logger.warn('Gitea getAuthenticatedUser failed:', err instanceof Error ? err.message : err);
return null;
}
}
// --- Admin Setup ---
/**
* Create the docs-comments repository if it doesn't exist
*/
async createRepo(): Promise<GiteaRepo | null> {
if (!(await this.isEnabled())) return null;
try {
const repoPath = await this.getRepoPath();
// Check if repo already exists
try {
const existing = await this.request<GiteaRepo>(
'GET',
`/repos/${repoPath}`,
);
logger.info(`Gitea: repo ${existing.full_name} already exists`);
return existing;
} catch {
// Repo doesn't exist, create it
}
const config = await this.getConfig();
const repo = await this.request<GiteaRepo>('POST', `/user/repos`, {
name: config.repoName,
description: 'Documentation page comments — managed by Changemaker Lite',
private: false,
auto_init: true,
});
logger.info(`Gitea: created repo ${repo.full_name}`);
return repo;
} catch (err) {
logger.warn('Gitea createRepo failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* Find or create a label in the comments repo
*/
async findOrCreateLabel(name: string, color: string): Promise<GiteaLabel | null> {
if (!(await this.isEnabled())) return null;
try {
const repoPath = await this.getRepoPath();
const labels = await this.request<GiteaLabel[]>(
'GET',
`/repos/${repoPath}/labels`,
);
const existing = labels.find((l) => l.name === name);
if (existing) return existing;
return await this.request<GiteaLabel>(
'POST',
`/repos/${repoPath}/labels`,
{ name, color },
);
} catch (err) {
logger.warn(`Gitea findOrCreateLabel("${name}") failed:`, err instanceof Error ? err.message : err);
return null;
}
}
/**
* Get the OAuth2 authorize URL for the widget to redirect to
*/
getAuthorizeUrl(): string {
return `${this.baseUrl}/login/oauth/authorize`;
}
// --- Admin User Management (for user provisioning) ---
/**
* Create a new Gitea user via admin API
*/
async adminCreateUser(data: {
email: string;
full_name: string;
login_name: string;
username: string;
password: string;
must_change_password?: boolean;
send_notify?: boolean;
}): Promise<GiteaUser> {
return this.request<GiteaUser>('POST', '/admin/users', {
email: data.email,
full_name: data.full_name,
login_name: data.login_name,
username: data.username,
password: data.password,
must_change_password: data.must_change_password ?? false,
send_notify: data.send_notify ?? false,
visibility: 'limited',
});
}
/**
* Update an existing Gitea user via admin API
*/
async adminUpdateUser(username: string, data: {
email?: string;
full_name?: string;
login_name?: string;
admin?: boolean;
active?: boolean;
}): Promise<GiteaUser> {
return this.request<GiteaUser>('PATCH', `/admin/users/${encodeURIComponent(username)}`, data as Record<string, unknown>);
}
/**
* Delete a Gitea user via admin API
*/
async adminDeleteUser(username: string): Promise<void> {
await this.request('DELETE', `/admin/users/${encodeURIComponent(username)}`);
}
/**
* Find a Gitea user by email (searches all users)
*/
async findUserByEmail(email: string): Promise<GiteaUser | null> {
try {
const users = await this.request<GiteaUser[]>(
'GET',
`/admin/users?limit=50`,
);
return users.find(u => u.email.toLowerCase() === email.toLowerCase()) || null;
} catch (err) {
logger.warn('Gitea findUserByEmail failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* Get a specific Gitea user by username
*/
async getUserByUsername(username: string): Promise<GiteaUser | null> {
try {
return await this.request<GiteaUser>('GET', `/users/${encodeURIComponent(username)}`);
} catch {
return null;
}
}
// --- Dashboard / Stats ---
/**
* List repositories (sorted by most recently updated)
*/
async listRepos(limit = 50): Promise<Array<{
id: number;
name: string;
full_name: string;
description: string;
updated_at: string;
stars_count: number;
forks_count: number;
open_issues_count: number;
}>> {
try {
return await this.request('GET', `/repos/search?limit=${limit}&sort=updated&order=desc`);
} catch (err) {
logger.warn('Gitea listRepos failed:', err instanceof Error ? err.message : err);
return [];
}
}
/**
* List recent commits for a repository
*/
async listRepoCommits(owner: string, repo: string, limit = 10): Promise<Array<{
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
};
}>> {
try {
return await this.request(
'GET',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?limit=${limit}`,
);
} catch (err) {
logger.warn(`Gitea listRepoCommits(${owner}/${repo}) failed:`, err instanceof Error ? err.message : err);
return [];
}
}
/**
* List all users via admin API (for dashboard stats)
*/
async listAllUsers(limit = 50): Promise<GiteaUser[]> {
try {
return await this.request<GiteaUser[]>('GET', `/admin/users?limit=${limit}`);
} catch (err) {
logger.warn('Gitea listAllUsers failed:', err instanceof Error ? err.message : err);
return [];
}
}
/**
* Create an API token for a user (for SSO / iframe embedding)
*/
async createUserToken(username: string, tokenName: string): Promise<{ id: number; name: string; sha1: string }> {
return this.request<{ id: number; name: string; sha1: string }>(
'POST',
`/users/${encodeURIComponent(username)}/tokens`,
{ name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>,
);
}
}
export const giteaClient = new GiteaClient();

View File

@@ -1,7 +1,7 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { listmonkClient } from './listmonk.client';
import { listmonkSyncService } from './listmonk-sync.service';
import { listmonkSyncService, SUPPORT_LEVEL_LIST_MAP } from './listmonk-sync.service';
/**
* Event-driven Listmonk sync — fire-and-forget subscriber upserts
@@ -244,6 +244,153 @@ class ListmonkEventSyncService {
}
}
/**
* Sync an address update (e.g. support level change from canvass visit) to Listmonk lists.
*/
async onAddressUpdated(data: {
email: string;
name: string;
supportLevel?: string | null;
sign?: boolean;
address?: string | null;
}): Promise<void> {
if (!this.enabled) return;
try {
await listmonkSyncService.ensureInitialized();
const allContactsId = listmonkSyncService.getListId('All Contacts');
const locationsAllId = listmonkSyncService.getListId('Locations - All');
if (!allContactsId || !locationsAllId) return;
const listIds = [allContactsId, locationsAllId];
// Add support level list
if (data.supportLevel && SUPPORT_LEVEL_LIST_MAP[data.supportLevel]) {
const levelListId = listmonkSyncService.getListId(SUPPORT_LEVEL_LIST_MAP[data.supportLevel]);
if (levelListId) listIds.push(levelListId);
}
// Add sign list
if (data.sign) {
const signListId = listmonkSyncService.getListId('Has Campaign Sign');
if (signListId) listIds.push(signListId);
}
await listmonkClient.upsertSubscriber(
data.email,
data.name,
listIds,
{
source: 'canvass_visit',
address: data.address || null,
support_level: data.supportLevel || null,
sign: data.sign ?? false,
last_synced: new Date().toISOString(),
},
);
this.incrementCounter();
logger.debug(`Listmonk event sync: address updated for ${data.email}`);
} catch (err) {
logger.debug('Listmonk event sync failed (onAddressUpdated):', err);
}
}
/**
* Sync a re-engagement email send to Listmonk "All Contacts" + "Volunteers" lists.
*/
async onReengagementSent(data: {
email: string;
name: string;
}): Promise<void> {
if (!this.enabled) return;
try {
await listmonkSyncService.ensureInitialized();
const allContactsId = listmonkSyncService.getListId('All Contacts');
const volunteersId = listmonkSyncService.getListId('Volunteers');
if (!allContactsId || !volunteersId) return;
await listmonkClient.upsertSubscriber(
data.email,
data.name,
[allContactsId, volunteersId],
{
source: 'reengagement',
last_reengagement_sent: new Date().toISOString(),
last_synced: new Date().toISOString(),
},
);
this.incrementCounter();
logger.debug(`Listmonk event sync: reengagement sent for ${data.email}`);
} catch (err) {
logger.debug('Listmonk event sync failed (onReengagementSent):', err);
}
}
/**
* Sync tag changes on a contact to Listmonk lists associated with CRM tags.
* For added tags: upsert subscriber to the corresponding Listmonk list.
* For removed tags: remove subscriber from the corresponding Listmonk list.
*/
async onContactTagsChanged(data: {
email: string;
name: string;
addedTags: string[];
removedTags: string[];
}): Promise<void> {
if (!this.enabled) return;
try {
// Lazy import to avoid circular dependency
const { prisma } = await import('../config/database');
// Find CRM tags that have listmonkListId set
const allTagNames = [...data.addedTags, ...data.removedTags];
if (allTagNames.length === 0) return;
const crmTags = await prisma.crmTag.findMany({
where: { name: { in: allTagNames }, listmonkListId: { not: null } },
});
const tagMap = new Map(crmTags.map(t => [t.name, t.listmonkListId!]));
// Add to lists for added tags
const addListIds = data.addedTags
.map(t => tagMap.get(t))
.filter((id): id is number => id != null);
if (addListIds.length > 0) {
await listmonkClient.upsertSubscriber(
data.email,
data.name,
addListIds,
{ source: 'crm_tag', last_synced: new Date().toISOString() },
);
this.incrementCounter();
}
// Remove from lists for removed tags
const removeListIds = data.removedTags
.map(t => tagMap.get(t))
.filter((id): id is number => id != null);
if (removeListIds.length > 0) {
const subscriber = await listmonkClient.findSubscriberByEmail(data.email);
if (subscriber) {
const currentListIds = subscriber.lists.map(l => l.id);
await listmonkClient.removeSubscriberFromLists(
subscriber.id,
removeListIds,
data.email,
currentListIds,
);
this.incrementCounter();
}
}
logger.debug(`Listmonk event sync: tags changed for ${data.email} (+${data.addedTags.length}/-${data.removedTags.length})`);
} catch (err) {
logger.debug('Listmonk event sync failed (onContactTagsChanged):', err);
}
}
getStats(): {
enabled: boolean;
lastSyncAt: string | null;

View File

@@ -21,7 +21,7 @@ const LIST_DEFINITIONS: Array<{ name: string; tags: string[] }> = [
{ name: 'Donors', tags: ['v2', 'payments'] },
];
const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
export const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
LEVEL_1: 'Support Level 1 (Strong)',
LEVEL_2: 'Support Level 2 (Likely)',
LEVEL_3: 'Support Level 3 (Unsure)',
@@ -228,14 +228,75 @@ class ListmonkSyncService {
return result;
}
async syncAll(): Promise<{ participants: BulkSyncResult; locations: BulkSyncResult; users: BulkSyncResult }> {
async syncCrmTags(): Promise<BulkSyncResult> {
await this.ensureInitialized();
const result: BulkSyncResult = { total: 0, success: 0, failed: 0, errors: [] };
// Find all CRM tags that have a Listmonk list linked
const crmTags = await prisma.crmTag.findMany({
where: { listmonkListId: { not: null } },
});
for (const tag of crmTags) {
try {
// Find all contacts with this tag using raw SQL (JSONB query)
const contacts = await prisma.$queryRaw<{ email: string; displayName: string }[]>`
SELECT email, "displayName"
FROM contacts
WHERE "mergedIntoId" IS NULL
AND email IS NOT NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
result.total += contacts.length;
for (const contact of contacts) {
try {
await listmonkClient.upsertSubscriber(
contact.email,
contact.displayName || '',
[tag.listmonkListId!],
{ source: 'crm_tag', tag_name: tag.name, last_synced: new Date().toISOString() },
);
result.success++;
} catch (err) {
result.failed++;
const msg = `Failed to sync tag "${tag.name}" for ${contact.email}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger.warn(msg);
}
}
// Update denormalized count
const countResult = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM contacts
WHERE "mergedIntoId" IS NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
await prisma.crmTag.update({
where: { id: tag.id },
data: { contactCount: Number(countResult[0]?.count ?? 0) },
});
} catch (err) {
const msg = `Failed to sync CRM tag "${tag.name}": ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger.warn(msg);
}
}
return result;
}
async syncAll(): Promise<{ participants: BulkSyncResult; locations: BulkSyncResult; users: BulkSyncResult; crmTags: BulkSyncResult }> {
this.lastError = null;
try {
const participants = await this.syncCampaignParticipants();
const locations = await this.syncLocations();
const users = await this.syncUsers();
const crmTags = await this.syncCrmTags();
this.lastSyncAt = new Date();
return { participants, locations, users };
return { participants, locations, users, crmTags };
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
throw err;

View File

@@ -171,6 +171,72 @@ class ListmonkClient {
return res.data;
}
async updateList(
id: number,
data: { name?: string; tags?: string[] },
): Promise<ListmonkList> {
this.assertEnabled();
const res = await this.request<{ data: ListmonkList }>('PUT', `/api/lists/${id}`, data);
return res.data;
}
async deleteList(id: number): Promise<void> {
this.assertEnabled();
await this.request<unknown>('DELETE', `/api/lists/${id}`);
}
async removeSubscriberFromLists(
subscriberId: number,
listIdsToRemove: number[],
currentEmail: string,
currentListIds: number[],
): Promise<ListmonkSubscriber> {
this.assertEnabled();
const filteredIds = currentListIds.filter(id => !listIdsToRemove.includes(id));
return this.updateSubscriber(subscriberId, {
email: currentEmail,
lists: filteredIds,
});
}
/**
* Get campaigns with stats (for dashboard)
*/
async getCampaigns(): Promise<Array<{
id: number;
name: string;
status: string;
sent: number;
views: number;
clicks: number;
started_at: string | null;
updated_at: string;
}>> {
try {
const res = await this.request<{ data: { results: Array<{
id: number;
name: string;
status: string;
stats: { sent: number; views: number; clicks: number };
started_at: string | null;
updated_at: string;
}> } }>('GET', '/api/campaigns?per_page=all&order_by=updated_at&order=desc');
return (res.data.results || []).map(c => ({
id: c.id,
name: c.name,
status: c.status,
sent: c.stats?.sent || 0,
views: c.stats?.views || 0,
clicks: c.stats?.clicks || 0,
started_at: c.started_at,
updated_at: c.updated_at,
}));
} catch (err) {
logger.warn('Failed to fetch Listmonk campaigns:', err instanceof Error ? err.message : err);
return [];
}
}
async upsertSubscriber(
email: string,
name: string,

View File

@@ -84,6 +84,26 @@ interface VolunteerShiftReminderJob {
shiftStatus: string;
}
interface VolunteerShiftThankYouJob {
type: 'volunteer-shift-thank-you';
volunteerEmail: string;
volunteerName: string;
shiftTitle: string;
shiftDate: string;
shiftTime: string;
shiftLocation: string;
signupUrl: string;
}
interface VolunteerReengagementJob {
type: 'volunteer-reengagement';
volunteerEmail: string;
volunteerName: string;
lastActivityDate: string;
lastActivityType: string;
signupUrl: string;
}
type NotificationJobData =
| AdminShiftSignupJob
| AdminResponseSubmittedJob
@@ -91,7 +111,9 @@ type NotificationJobData =
| AdminShiftCancellationJob
| VolunteerSessionSummaryJob
| VolunteerCancellationJob
| VolunteerShiftReminderJob;
| VolunteerShiftReminderJob
| VolunteerShiftThankYouJob
| VolunteerReengagementJob;
// ─── Queue Service ─────────────────────────────────────────────────
@@ -152,6 +174,12 @@ class NotificationQueueService {
shiftStatus: data.shiftStatus,
});
break;
case 'volunteer-shift-thank-you':
await emailService.sendVolunteerShiftThankYou(data);
break;
case 'volunteer-reengagement':
await emailService.sendVolunteerReengagement(data);
break;
}
},
{
@@ -202,6 +230,45 @@ class NotificationQueueService {
return job.id!;
}
/**
* Schedule a post-shift thank-you email as a delayed job.
* Fires 2 hours after shift end time. Uses deterministic jobId for cancellation.
*/
async scheduleShiftThankYou(
data: VolunteerShiftThankYouJob,
shiftEndDatetime: Date,
): Promise<string | null> {
const thankYouTime = new Date(shiftEndDatetime.getTime() + 2 * 60 * 60 * 1000);
const delay = thankYouTime.getTime() - Date.now();
if (delay <= 0) {
logger.debug('Shift has already ended 2h+ ago, skipping thank-you scheduling');
return null;
}
const jobId = `shift-thankyou-${data.volunteerEmail}-${shiftEndDatetime.getTime()}`;
const job = await this.queue.add(data.type, data, {
delay,
jobId,
});
logger.info(`Scheduled shift thank-you jobId=${jobId} delay=${Math.round(delay / 60000)}min`);
return job.id!;
}
/** Cancel a pending shift thank-you. */
async cancelShiftThankYou(email: string, shiftEndDatetime: Date): Promise<void> {
const jobId = `shift-thankyou-${email}-${shiftEndDatetime.getTime()}`;
try {
const job = await this.queue.getJob(jobId);
if (job) {
await job.remove();
logger.info(`Cancelled shift thank-you jobId=${jobId}`);
}
} catch (err) {
logger.warn(`Failed to cancel shift thank-you jobId=${jobId}:`, err);
}
}
/** Cancel a pending shift reminder. */
async cancelShiftReminder(email: string, shiftDatetime: Date): Promise<void> {
const jobId = `shift-reminder-${email}-${shiftDatetime.getTime()}`;

View File

@@ -0,0 +1,197 @@
import Redis from 'ioredis';
import { prisma } from '../config/database';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { siteSettingsService } from '../modules/settings/settings.service';
import { notificationQueueService } from './notification-queue.service';
import { listmonkEventSyncService } from './listmonk-event-sync.service';
/**
* Volunteer Re-Engagement Scanner
*
* Queries volunteers whose last activity (ShiftSignup or CanvassSession)
* exceeds the configured inactivity threshold. Sends a re-engagement email
* with a Redis-based cooldown to prevent spamming.
*/
class ReengagementService {
private redis: Redis | null = null;
private getRedis(): Redis {
if (!this.redis) {
this.redis = new Redis(env.REDIS_URL);
}
return this.redis;
}
/**
* Run the re-engagement scan. Called daily from server.ts.
*/
async scan(): Promise<{ scanned: number; sent: number; skipped: number }> {
const settings = await siteSettingsService.get();
if (!settings.notifyVolunteerReengagement) {
return { scanned: 0, sent: 0, skipped: 0 };
}
const inactiveDays = settings.reengagementInactiveDays || 30;
const cooldownDays = settings.reengagementCooldownDays || 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - inactiveDays);
// Find volunteers who have past shift signups or canvass sessions
// but whose LAST activity is before the cutoff date.
const volunteers = await this.findInactiveVolunteers(cutoffDate);
let sent = 0;
let skipped = 0;
const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
for (const volunteer of volunteers) {
try {
// Check Redis cooldown
const cooldownKey = `reengagement:${volunteer.email}`;
const redis = this.getRedis();
const exists = await redis.exists(cooldownKey);
if (exists) {
skipped++;
continue;
}
// Enqueue the re-engagement notification
await notificationQueueService.enqueue({
type: 'volunteer-reengagement',
volunteerEmail: volunteer.email,
volunteerName: volunteer.name || volunteer.email,
lastActivityDate: volunteer.lastActivityDate.toLocaleDateString('en-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
lastActivityType: volunteer.lastActivityType,
signupUrl,
});
// Set cooldown in Redis (expires after cooldownDays)
const cooldownSeconds = cooldownDays * 24 * 60 * 60;
await redis.set(cooldownKey, '', 'EX', cooldownSeconds);
// Listmonk event sync: tag as re-engaged
listmonkEventSyncService.onReengagementSent({
email: volunteer.email,
name: volunteer.name || volunteer.email,
}).catch(() => {});
sent++;
} catch (err) {
logger.error(`Re-engagement failed for ${volunteer.email}:`, err);
skipped++;
}
}
if (sent > 0 || skipped > 0) {
logger.info(`Re-engagement scan: ${volunteers.length} inactive volunteers found, ${sent} emails queued, ${skipped} skipped (cooldown/error)`);
}
return { scanned: volunteers.length, sent, skipped };
}
/**
* Find volunteers whose last activity is before the cutoff date.
* Activity = ShiftSignup (confirmed) OR CanvassSession (completed).
*/
private async findInactiveVolunteers(cutoffDate: Date): Promise<Array<{
email: string;
name: string | null;
lastActivityDate: Date;
lastActivityType: string;
}>> {
// Get users who have at least one signup or canvass session
// and whose MOST RECENT activity is before the cutoff.
// We use two separate queries and merge results.
// 1. Users with shift signups (last signup before cutoff)
const shiftVolunteers = await prisma.shiftSignup.groupBy({
by: ['userEmail', 'userName'],
_max: { signupDate: true },
where: { status: 'CONFIRMED' },
having: {
signupDate: { _max: { lt: cutoffDate } },
},
});
// 2. Users with canvass sessions (last session before cutoff)
const canvassVolunteers = await prisma.canvassSession.groupBy({
by: ['userId'],
_max: { startedAt: true },
where: { status: 'COMPLETED' },
having: {
startedAt: { _max: { lt: cutoffDate } },
},
});
// Look up user details for canvass volunteers
const canvassUserIds = canvassVolunteers.map((c) => c.userId);
const canvassUsers = canvassUserIds.length > 0
? await prisma.user.findMany({
where: { id: { in: canvassUserIds } },
select: { id: true, email: true, name: true },
})
: [];
const canvassUserMap = new Map(canvassUsers.map((u) => [u.id, u]));
// Merge into a single list, taking the most recent activity per email
const volunteerMap = new Map<string, {
email: string;
name: string | null;
lastActivityDate: Date;
lastActivityType: string;
}>();
for (const sv of shiftVolunteers) {
const date = sv._max.signupDate;
if (!date) continue;
const email = sv.userEmail;
const existing = volunteerMap.get(email);
if (!existing || date > existing.lastActivityDate) {
volunteerMap.set(email, {
email,
name: sv.userName,
lastActivityDate: date,
lastActivityType: 'Shift Signup',
});
}
}
for (const cv of canvassVolunteers) {
const date = cv._max?.startedAt;
if (!date) continue;
const user = canvassUserMap.get(cv.userId);
if (!user) continue;
const existing = volunteerMap.get(user.email);
if (!existing || date > existing.lastActivityDate) {
volunteerMap.set(user.email, {
email: user.email,
name: user.name,
lastActivityDate: date,
lastActivityType: 'Canvass Session',
});
}
}
// Filter out entries where the merged last activity is actually after cutoff
// (e.g., shift signup was old but canvass session was recent)
return Array.from(volunteerMap.values()).filter(
(v) => v.lastActivityDate < cutoffDate,
);
}
async close() {
if (this.redis) {
this.redis.disconnect();
this.redis = null;
}
}
}
export const reengagementService = new ReengagementService();

View File

@@ -284,6 +284,81 @@ class RocketChatClient {
}
}
// --- Direct Messages ---
/**
* Create or get a DM room between two users (by username).
* RC im.create is idempotent — returns existing room if it already exists.
*/
async createDM(usernames: string[]): Promise<{ roomId: string; usernames: string[] }> {
const data = await this.request<{
room: { _id: string; usernames: string[] };
success: boolean;
}>('POST', '/im.create', { usernames: usernames.join(',') });
return {
roomId: data.room._id,
usernames: data.room.usernames || usernames,
};
}
// --- Dashboard / Stats ---
/**
* Get server-wide statistics
*/
async getStatistics(): Promise<{
totalUsers: number;
onlineUsers: number;
totalChannels: number;
totalMessages: number;
} | null> {
try {
const data = await this.request<{
statistics: {
totalUsers: number;
onlineUsers: number;
totalRooms: number;
totalChannels: number;
totalMessages: number;
};
success: boolean;
}>('GET', '/statistics');
return {
totalUsers: data.statistics.totalUsers,
onlineUsers: data.statistics.onlineUsers,
totalChannels: data.statistics.totalChannels,
totalMessages: data.statistics.totalMessages,
};
} catch (err) {
logger.warn('RC getStatistics failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* List channels with message counts (for dashboard top channels)
*/
async listChannels(limit = 10): Promise<Array<{
name: string;
msgs: number;
usersCount: number;
}>> {
try {
const data = await this.request<{
channels: Array<{
name: string;
msgs: number;
usersCount: number;
}>;
success: boolean;
}>('GET', `/channels.list?count=${limit}&sort=${encodeURIComponent('{"msgs":-1}')}`);
return data.channels || [];
} catch (err) {
logger.warn('RC listChannels failed:', err instanceof Error ? err.message : err);
return [];
}
}
// --- Webhooks ---
/**

View File

@@ -0,0 +1,68 @@
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { termuxClient } from './termux.client';
class SmsDeviceMonitorService {
private interval: ReturnType<typeof setInterval> | null = null;
start() {
if (this.interval) return;
// Initial check
this.check().catch((err) => {
logger.warn('Initial SMS device check failed:', err instanceof Error ? err.message : err);
});
this.interval = setInterval(() => {
this.check().catch((err) => {
logger.warn('SMS device check failed:', err instanceof Error ? err.message : err);
});
}, env.SMS_DEVICE_MONITOR_INTERVAL_MS);
logger.info(`SMS device monitor started (interval: ${env.SMS_DEVICE_MONITOR_INTERVAL_MS}ms)`);
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
async check() {
const [isAvailable, battery, health] = await Promise.all([
termuxClient.isAvailable(),
termuxClient.getBattery(),
termuxClient.getHealth(),
]);
await prisma.smsDeviceStatus.create({
data: {
isConnected: isAvailable,
connectionType: isAvailable ? 'termux' : null,
batteryLevel: battery?.percentage ?? null,
batteryStatus: battery?.status ?? null,
totalSent: health?.messages_sent ?? 0,
lastCheckedAt: new Date(),
},
});
// Prune old entries — keep last 100
const count = await prisma.smsDeviceStatus.count();
if (count > 100) {
const oldest = await prisma.smsDeviceStatus.findMany({
orderBy: { lastCheckedAt: 'asc' },
take: count - 100,
select: { id: true },
});
if (oldest.length > 0) {
await prisma.smsDeviceStatus.deleteMany({
where: { id: { in: oldest.map((r) => r.id) } },
});
}
}
}
}
export const smsDeviceMonitorService = new SmsDeviceMonitorService();

View File

@@ -0,0 +1,245 @@
import { Queue, Worker, type Job } from 'bullmq';
import { SmsMessageStatus } from '@prisma/client';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { termuxClient } from './termux.client';
interface SmsJobData {
recipientId: string;
campaignId: string;
phone: string;
message: string;
attemptNumber: number;
}
class SmsQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue('sms-campaigns', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: env.SMS_MAX_RETRIES,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 24 * 60 * 60, count: 1000 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
});
}
startWorker() {
this.worker = new Worker(
'sms-campaigns',
async (job: Job<SmsJobData>) => {
const { recipientId, campaignId, phone, message } = job.data;
logger.info(`Processing SMS job ${job.id} for campaign ${campaignId}, phone ${phone}`);
// Check if campaign is still RUNNING (support pause)
const campaign = await prisma.smsCampaign.findUnique({
where: { id: campaignId },
select: { status: true },
});
if (!campaign || campaign.status !== 'RUNNING') {
logger.info(`Campaign ${campaignId} is ${campaign?.status || 'deleted'}, skipping SMS to ${phone}`);
return { skipped: true, reason: 'campaign_not_running' };
}
// Send SMS via Termux
const result = await termuxClient.sendSms(phone, message);
const status: SmsMessageStatus = result.success ? 'SENT' : 'FAILED';
// Update recipient status
await prisma.smsCampaignRecipient.update({
where: { id: recipientId },
data: {
status,
sentAt: result.success ? new Date() : undefined,
errorMessage: result.error || undefined,
},
});
// Create SmsMessage record
const smsMessage = await prisma.smsMessage.create({
data: {
phone,
message,
direction: 'OUTBOUND',
status,
connectionType: 'termux',
campaignId,
},
});
// Create or update conversation
const conversation = await prisma.smsConversation.upsert({
where: { phone_campaignId: { phone, campaignId } },
create: {
phone,
campaignId,
totalMessages: 1,
lastMessageAt: new Date(),
},
update: {
totalMessages: { increment: 1 },
lastMessageAt: new Date(),
},
});
// Link message to conversation
await prisma.smsMessage.update({
where: { id: smsMessage.id },
data: { conversationId: conversation.id },
});
// Record outbound SMS as ContactActivity if conversation has a contactId
if (conversation.contactId) {
try {
await prisma.contactActivity.create({
data: {
contactId: conversation.contactId,
type: 'SMS_SENT',
title: 'SMS sent',
description: message.length > 200 ? message.slice(0, 200) + '...' : message,
metadata: {
phone,
conversationId: conversation.id,
campaignId,
},
},
});
} catch (err) {
logger.debug('Failed to record outbound SMS ContactActivity:', err);
}
}
// Update campaign counters
if (result.success) {
await prisma.smsCampaign.update({
where: { id: campaignId },
data: { totalSent: { increment: 1 } },
});
} else {
await prisma.smsCampaign.update({
where: { id: campaignId },
data: { totalFailed: { increment: 1 } },
});
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
}
return { success: true, phone };
},
{
connection: { url: env.REDIS_URL },
concurrency: 1, // Serial — Termux/carrier rate limit
},
);
this.worker.on('completed', (job) => {
logger.debug(`SMS job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`SMS job ${job?.id} failed: ${err.message}`);
});
logger.info('SMS queue worker started');
}
/**
* Enqueue a single SMS job with configurable delay
*/
async addSmsJob(data: SmsJobData, delayMs = 0): Promise<string> {
const job = await this.queue.add('sms', data, {
delay: delayMs,
});
return job.id!;
}
/**
* Enqueue all pending recipients for a campaign
*/
async enqueueCampaignRecipients(campaignId: string, delayBetweenMs: number) {
const recipients = await prisma.smsCampaignRecipient.findMany({
where: { campaignId, status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
// Get campaign template for message substitution
const campaign = await prisma.smsCampaign.findUnique({
where: { id: campaignId },
select: { messageTemplate: true },
});
if (!campaign) throw new Error('Campaign not found');
let enqueued = 0;
for (const recipient of recipients) {
// Substitute template variables
const message = substituteTemplate(campaign.messageTemplate, {
name: recipient.name || '',
phone: recipient.phone,
});
await this.addSmsJob(
{
recipientId: recipient.id,
campaignId,
phone: recipient.phone,
message,
attemptNumber: 1,
},
enqueued * delayBetweenMs,
);
enqueued++;
}
return enqueued;
}
async getStats() {
const [waiting, active, completed, failed, paused] = await Promise.all([
this.queue.getWaitingCount(),
this.queue.getActiveCount(),
this.queue.getCompletedCount(),
this.queue.getFailedCount(),
this.queue.isPaused(),
]);
return { waiting, active, completed, failed, paused };
}
async pause() {
await this.queue.pause();
logger.info('SMS queue paused');
}
async resume() {
await this.queue.resume();
logger.info('SMS queue resumed');
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('SMS queue closed');
}
}
/**
* Substitute template variables like {name}, {phone} in a message.
*/
function substituteTemplate(
template: string,
vars: Record<string, string>,
): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
return vars[key] !== undefined ? vars[key] : match;
});
}
export const smsQueueService = new SmsQueueService();

View File

@@ -0,0 +1,207 @@
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { termuxClient } from './termux.client';
import type { SmsResponseType } from '@prisma/client';
// Opt-out keywords (case-insensitive)
const OPT_OUT_KEYWORDS = ['stop', 'unsubscribe', 'cancel', 'quit', 'remove', 'optout', 'opt out', 'end'];
const POSITIVE_KEYWORDS = ['yes', 'yeah', 'yep', 'sure', 'ok', 'okay', 'great', 'thanks', 'thank you', 'sounds good', 'interested', 'count me in'];
const NEGATIVE_KEYWORDS = ['no', 'nah', 'nope', 'not interested', 'no thanks', 'no thank you', 'pass', 'decline'];
/**
* Classify an inbound SMS response by content
*/
function classifyResponse(text: string): SmsResponseType {
const lower = text.toLowerCase().trim();
if (OPT_OUT_KEYWORDS.some((kw) => lower === kw || lower.startsWith(kw + ' '))) {
return 'OPT_OUT';
}
if (POSITIVE_KEYWORDS.some((kw) => lower === kw || lower.startsWith(kw + ' ') || lower.startsWith(kw + '!'))) {
return 'POSITIVE';
}
if (NEGATIVE_KEYWORDS.some((kw) => lower === kw || lower.startsWith(kw + ' '))) {
return 'NEGATIVE';
}
if (lower.includes('?')) {
return 'QUESTION';
}
return 'NEUTRAL';
}
/**
* Normalize phone to last 10 digits for matching
*/
function normalizeLast10(phone: string): string {
const digits = phone.replace(/\D/g, '');
return digits.length >= 10 ? digits.slice(-10) : digits;
}
class SmsResponseSyncService {
private interval: ReturnType<typeof setInterval> | null = null;
private lastSyncTimestamp: string | null = null;
start() {
if (this.interval) return;
// Initial sync
this.sync().catch((err) => {
logger.warn('Initial SMS response sync failed:', err instanceof Error ? err.message : err);
});
this.interval = setInterval(() => {
this.sync().catch((err) => {
logger.warn('SMS response sync failed:', err instanceof Error ? err.message : err);
});
}, env.SMS_RESPONSE_SYNC_INTERVAL_MS);
logger.info(`SMS response sync started (interval: ${env.SMS_RESPONSE_SYNC_INTERVAL_MS}ms)`);
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
async sync() {
const messages = await termuxClient.getInbox(this.lastSyncTimestamp || undefined, 200);
if (messages.length === 0) return;
let processed = 0;
for (const msg of messages) {
if (!msg.number || !msg.body) continue;
const phone10 = normalizeLast10(msg.number);
if (phone10.length < 10) continue;
// Check if we already have this message (dedup by phone + body + date)
const existingMsg = await prisma.smsMessage.findFirst({
where: {
phone: { endsWith: phone10 },
direction: 'INBOUND',
message: msg.body,
sentAt: msg.date ? new Date(msg.date) : undefined,
},
});
if (existingMsg) continue;
// Find matching conversation(s) — by normalized phone suffix
const conversations = await prisma.smsConversation.findMany({
where: {
phone: { endsWith: phone10 },
status: { not: 'CLOSED' },
},
orderBy: { lastMessageAt: 'desc' },
take: 1,
});
const conversation = conversations[0] || null;
const responseType = classifyResponse(msg.body);
// Create inbound message record
const smsMessage = await prisma.smsMessage.create({
data: {
phone: msg.number,
message: msg.body,
direction: 'INBOUND',
status: 'DELIVERED',
responseType,
campaignId: conversation?.campaignId || undefined,
conversationId: conversation?.id || undefined,
sentAt: msg.date ? new Date(msg.date) : new Date(),
},
});
// Update conversation stats if we have one
if (conversation) {
const updates: Record<string, unknown> = {
totalMessages: { increment: 1 },
totalResponses: { increment: 1 },
unreadCount: { increment: 1 },
lastMessageAt: smsMessage.sentAt,
lastResponseAt: smsMessage.sentAt,
};
// Handle opt-out
if (responseType === 'OPT_OUT') {
updates.status = 'OPTED_OUT';
}
// Auto-link conversation to CRM contact by phone number
if (!conversation.contactId) {
try {
const contactPhone = await prisma.contactPhone.findFirst({
where: { phone: { endsWith: phone10 } },
select: { contactId: true },
});
if (contactPhone) {
updates.contactId = contactPhone.contactId;
logger.debug(`Auto-linked conversation ${conversation.id} to contact ${contactPhone.contactId}`);
}
} catch (err) {
logger.debug('Failed to auto-link conversation to contact:', err);
}
}
await prisma.smsConversation.update({
where: { id: conversation.id },
data: updates,
});
// Record inbound SMS as ContactActivity if conversation has a contactId
const updatedContactId = (updates.contactId as string) || conversation.contactId;
if (updatedContactId) {
try {
await prisma.contactActivity.create({
data: {
contactId: updatedContactId,
type: 'SMS_RECEIVED',
title: 'SMS received',
description: msg.body.length > 200 ? msg.body.slice(0, 200) + '...' : msg.body,
metadata: {
phone: msg.number,
responseType,
conversationId: conversation.id,
campaignId: conversation.campaignId || null,
},
},
});
} catch (err) {
logger.debug('Failed to record inbound SMS ContactActivity:', err);
}
}
// Increment campaign totalResponded
if (conversation.campaignId) {
await prisma.smsCampaign.update({
where: { id: conversation.campaignId },
data: { totalResponded: { increment: 1 } },
});
}
}
processed++;
}
// Update last sync timestamp
if (messages.length > 0) {
const latestDate = messages
.filter((m) => m.date)
.map((m) => new Date(m.date).getTime())
.sort((a, b) => b - a)[0];
if (latestDate) {
this.lastSyncTimestamp = new Date(latestDate).toISOString();
}
}
if (processed > 0) {
logger.info(`SMS response sync: processed ${processed} new inbound messages`);
}
}
}
export const smsResponseSyncService = new SmsResponseSyncService();

View File

@@ -0,0 +1,211 @@
import { prisma } from '../config/database';
import { redis } from '../config/redis';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { emailService } from './email.service';
import { feedService, type FeedItem } from '../modules/social/feed.service';
import { notificationService } from '../modules/social/notification.service';
import { friendshipService } from '../modules/social/friendship.service';
import * as fs from 'fs';
import * as path from 'path';
const REDIS_PREFIX = 'social:digest:last:';
export const socialDigestService = {
/** Scan for users needing a digest and send emails */
async scan() {
// Check if social is enabled
const settings = await prisma.siteSettings.findFirst({
select: { enableSocial: true, organizationName: true },
});
if (!settings?.enableSocial) return;
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sunday
// Find users opted into digests
const prefs = await prisma.notificationPreferences.findMany({
where: {
digestFrequency: { in: ['daily', 'weekly'] },
emailNotifications: true,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
let sent = 0;
for (const pref of prefs) {
try {
// Weekly digests only on Mondays
if (pref.digestFrequency === 'weekly' && dayOfWeek !== 1) continue;
// Check Redis cooldown (prevent double-sends)
const cooldownKey = `${REDIS_PREFIX}${pref.userId}`;
const lastSent = await redis.get(cooldownKey);
if (lastSent) continue;
const shouldSend = await this.shouldSendDigest(pref.userId, pref.digestFrequency);
if (!shouldSend) continue;
await this.sendDigest(
pref.userId,
pref.user.email,
pref.user.name || pref.user.email,
pref.digestFrequency,
settings.organizationName || 'Changemaker',
env.DOMAIN,
);
// Set cooldown: daily=20h, weekly=6d (prevents duplicates even with timing variance)
const cooldownSeconds = pref.digestFrequency === 'daily' ? 72000 : 518400;
await redis.set(cooldownKey, now.toISOString(), 'EX', cooldownSeconds);
// Update lastDigestSentAt
await prisma.notificationPreferences.update({
where: { userId: pref.userId },
data: { lastDigestSentAt: now },
});
sent++;
} catch (err) {
logger.error(`Failed to send social digest to ${pref.user.email}`, { error: err });
}
}
if (sent > 0) {
logger.info(`Social digest: sent ${sent} emails`);
}
},
/** Determine if a user should receive a digest based on timing */
async shouldSendDigest(userId: string, frequency: string): Promise<boolean> {
const pref = await prisma.notificationPreferences.findUnique({
where: { userId },
select: { lastDigestSentAt: true },
});
if (!pref?.lastDigestSentAt) return true; // Never sent before
const hoursSinceLastDigest = (Date.now() - pref.lastDigestSentAt.getTime()) / (1000 * 60 * 60);
if (frequency === 'daily') return hoursSinceLastDigest >= 20;
if (frequency === 'weekly') return hoursSinceLastDigest >= 144; // 6 days
return false;
},
/** Generate and send a digest email */
async sendDigest(
userId: string,
email: string,
name: string,
frequency: string,
orgName: string,
domain: string,
) {
// Calculate "since" based on frequency
const since = new Date();
if (frequency === 'daily') {
since.setDate(since.getDate() - 1);
} else {
since.setDate(since.getDate() - 7);
}
// Gather data
const [friendFeedResult, unreadCount, pendingReceived] = await Promise.all([
feedService.getFriendFeed(userId, 1, 10),
notificationService.getUnreadCount(userId),
friendshipService.listPendingReceived(userId),
]);
const activities: FeedItem[] = friendFeedResult.items;
const pendingCount = pendingReceived.length;
// Skip digest if nothing to report
if (activities.length === 0 && unreadCount === 0 && pendingCount === 0) return;
// Build HTML sections
const activitySection = activities.length > 0
? `<div class="section-title">Friend Activity</div>${activities.slice(0, 5).map((a) =>
`<div class="activity-item">
<span class="activity-name">${escapeHtml(a.userName || a.userEmail)}</span>
<span class="activity-desc">${escapeHtml(a.title)}</span>
</div>`,
).join('')}${activities.length > 5 ? `<p style="color:#999; font-size:13px;">...and ${activities.length - 5} more</p>` : ''}`
: '';
const pendingSection = pendingCount > 0
? `<div class="section-title">Pending Friend Requests</div><p>You have ${pendingCount} pending friend request${pendingCount > 1 ? 's' : ''} waiting for your response.</p>`
: '';
// Build text versions
const activityText = activities.length > 0
? `Recent friend activity:\n${activities.slice(0, 5).map((a) => ` - ${a.userName || a.userEmail}: ${a.title}`).join('\n')}`
: '';
const pendingText = pendingCount > 0
? `You have ${pendingCount} pending friend request${pendingCount > 1 ? 's' : ''}.`
: '';
const appUrl = `https://app.${domain}`;
const feedUrl = `${appUrl}/volunteer/feed`;
const settingsUrl = `${appUrl}/volunteer/notifications`;
const digestPeriod = frequency === 'daily' ? 'Daily' : 'Weekly';
// Load templates
let html: string;
let text: string;
try {
const templateDir = path.join(__dirname, '../templates/email');
html = fs.readFileSync(path.join(templateDir, 'social-digest.html'), 'utf-8');
text = fs.readFileSync(path.join(templateDir, 'social-digest.txt'), 'utf-8');
} catch {
logger.warn('Social digest templates not found, using fallback');
html = `<p>Hi ${escapeHtml(name)}, you have ${unreadCount} unread notifications and ${activities.length} friend activities. <a href="${feedUrl}">View feed</a></p>`;
text = `Hi ${name}, you have ${unreadCount} unread notifications and ${activities.length} friend activities. View feed: ${feedUrl}`;
}
// Replace placeholders
const replacements: Record<string, string> = {
'{{ORGANIZATION_NAME}}': escapeHtml(orgName),
'{{DIGEST_PERIOD}}': digestPeriod,
'{{USER_NAME}}': escapeHtml(name),
'{{UNREAD_NOTIFICATIONS}}': String(unreadCount),
'{{PENDING_REQUESTS}}': String(pendingCount),
'{{FRIEND_ACTIVITY_COUNT}}': String(activities.length),
'{{FRIEND_ACTIVITY_SECTION}}': activitySection,
'{{PENDING_REQUESTS_SECTION}}': pendingSection,
'{{FRIEND_ACTIVITY_TEXT}}': activityText,
'{{PENDING_REQUESTS_TEXT}}': pendingText,
'{{FEED_URL}}': feedUrl,
'{{SETTINGS_URL}}': settingsUrl,
};
for (const [key, value] of Object.entries(replacements)) {
html = html.replace(new RegExp(escapeRegExp(key), 'g'), value);
text = text.replace(new RegExp(escapeRegExp(key), 'g'), value);
}
await emailService.sendEmail({
to: email,
subject: `${digestPeriod} Social Digest — ${orgName}`,
html,
text,
});
},
};
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,127 @@
import { logger } from '../utils/logger';
// --- Types ---
export interface TailscaleDevice {
id: string;
name: string; // FQDN hostname
addresses: string[]; // IPv4 + IPv6 Tailscale addresses
os: string; // "android", "linux", "windows", etc.
hostname: string; // Short hostname
online: boolean;
lastSeen: string; // ISO 8601
clientVersion?: string;
updateAvailable?: boolean;
tags?: string[];
}
// --- Client ---
class TailscaleClient {
private apiKey = '';
private tailnet = '-'; // "-" = default tailnet (Tailscale API convention)
private get baseUrl(): string {
return 'https://api.tailscale.com/api/v2';
}
/** Configure client with an API key (and optional tailnet override). */
configure(apiKey: string, tailnet?: string): void {
this.apiKey = apiKey;
if (tailnet) this.tailnet = tailnet;
}
/** Load configuration from DB settings. */
async configureFromDb(): Promise<void> {
const { siteSettingsService } = await import('../modules/settings/settings.service');
const settings = await siteSettingsService.get();
this.apiKey = settings.smsTailscaleApiKey || '';
if (settings.smsTailscaleTailnet) {
this.tailnet = settings.smsTailscaleTailnet;
}
}
get configured(): boolean {
return !!this.apiKey;
}
private async request<T>(method: string, path: string): Promise<T> {
if (!this.apiKey) {
throw new Error('Tailscale API not configured. Provide an API key.');
}
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Tailscale API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return await res.json() as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Devices ---
async listDevices(): Promise<TailscaleDevice[]> {
const res = await this.request<{ devices?: TailscaleDevice[] }>(
'GET',
`/tailnet/${encodeURIComponent(this.tailnet)}/devices`,
);
return res.devices || [];
}
async getDevice(deviceId: string): Promise<TailscaleDevice> {
return this.request<TailscaleDevice>('GET', `/device/${encodeURIComponent(deviceId)}`);
}
// --- Helpers ---
/** Filter devices by Android OS. */
filterAndroidDevices(devices: TailscaleDevice[]): TailscaleDevice[] {
return devices.filter(d => d.os.toLowerCase() === 'android');
}
/** Extract the first IPv4 address from a device's address list. */
getDeviceIpv4(device: TailscaleDevice): string | null {
for (const addr of device.addresses) {
// IPv4 addresses don't contain ':'
if (!addr.includes(':')) {
return addr;
}
}
return null;
}
/** Quick health check: tries to list devices. */
async healthCheck(): Promise<boolean> {
try {
await this.listDevices();
return true;
} catch (err) {
logger.warn('Tailscale health check failed:', err instanceof Error ? err.message : err);
return false;
}
}
}
export { TailscaleClient };
export const tailscaleClient = new TailscaleClient();

View File

@@ -0,0 +1,281 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface TermuxSmsResult {
success: boolean;
error?: string;
timestamp?: string;
phone?: string;
message_length?: number;
total_sent?: number;
}
export interface TermuxInboxMessage {
number: string;
body: string;
date: string;
type: string; // "inbox" | "sent"
read: boolean;
threadId?: number;
}
export interface TermuxContact {
name: string;
number: string;
}
export interface TermuxBatteryStatus {
health: string;
percentage: number;
plugged: string;
status: string;
temperature: number;
}
export interface TermuxHealthResponse {
status: string;
uptime: number;
messages_sent: number;
device_ip?: string;
}
// --- Client ---
class TermuxClient {
// DB-sourced config (overrides env vars when non-empty)
private dbUrl = '';
private dbKey = '';
private dbEnabled: boolean | null = null;
private get baseUrl(): string {
return this.dbUrl || env.TERMUX_API_URL;
}
private get apiKey(): string {
return this.dbKey || env.TERMUX_API_KEY;
}
get enabled(): boolean {
const featureOn = this.dbEnabled === true || env.ENABLE_SMS === 'true';
return featureOn && !!this.baseUrl && !!this.apiKey;
}
/** Whether the active config came from the database (vs env vars). */
get configSource(): 'database' | 'env' {
return (this.dbUrl || this.dbKey) ? 'database' : 'env';
}
/** Load config from DB settings (called at startup + after save-config). */
async configureFromDb(): Promise<void> {
try {
const { siteSettingsService } = await import('../modules/settings/settings.service');
const settings = await siteSettingsService.get();
this.dbUrl = settings.smsTermuxApiUrl || '';
this.dbKey = settings.smsTermuxApiKey || '';
this.dbEnabled = settings.enableSms;
} catch (err) {
logger.warn('Failed to load SMS config from DB:', err instanceof Error ? err.message : err);
}
}
/**
* Make an authenticated request to the Termux API server.
* Auth via X-API-Key header (matches Termux server's HMAC auth).
*/
private async request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
timeoutMs = 15000,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const headers: Record<string, string> = {
'X-API-Key': this.apiKey,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, {
method,
headers,
body: fetchBody,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Termux API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return await res.json() as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Health ---
async isAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/health`, {
headers: { 'X-API-Key': this.apiKey },
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
async getHealth(): Promise<TermuxHealthResponse | null> {
if (!this.enabled) return null;
try {
return await this.request<TermuxHealthResponse>('GET', '/health');
} catch (err) {
logger.warn('Termux getHealth failed:', err instanceof Error ? err.message : err);
return null;
}
}
// --- SMS ---
async sendSms(phone: string, message: string): Promise<TermuxSmsResult> {
if (!this.enabled) {
return { success: false, error: 'SMS feature not enabled' };
}
try {
return await this.request<TermuxSmsResult>('POST', '/api/sms/send', {
phone,
message,
}, 30000); // 30s timeout for SMS send
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`Termux sendSms to ${phone} failed:`, errorMsg);
return { success: false, error: errorMsg };
}
}
/**
* Get inbox messages from the phone.
* @param since - ISO date string; only return messages newer than this
* @param limit - Max messages to return (default 100)
*/
async getInbox(since?: string, limit = 100): Promise<TermuxInboxMessage[]> {
if (!this.enabled) return [];
try {
const params = new URLSearchParams({ limit: String(limit), type: 'inbox' });
if (since) params.set('since', since);
const data = await this.request<{ messages?: TermuxInboxMessage[]; success?: boolean }>(
'GET',
`/api/sms/inbox?${params.toString()}`,
);
return data.messages || [];
} catch (err) {
logger.warn('Termux getInbox failed:', err instanceof Error ? err.message : err);
return [];
}
}
/**
* Get contacts from the phone's address book
*/
async getContacts(): Promise<TermuxContact[]> {
if (!this.enabled) return [];
try {
const data = await this.request<{ contacts?: TermuxContact[]; success?: boolean }>(
'GET',
'/api/contacts/list',
);
return data.contacts || [];
} catch (err) {
logger.warn('Termux getContacts failed:', err instanceof Error ? err.message : err);
return [];
}
}
// --- Device ---
async getBattery(): Promise<TermuxBatteryStatus | null> {
if (!this.enabled) return null;
try {
return await this.request<TermuxBatteryStatus>('GET', '/api/device/battery');
} catch (err) {
logger.warn('Termux getBattery failed:', err instanceof Error ? err.message : err);
return null;
}
}
async getDeviceInfo(): Promise<Record<string, unknown> | null> {
if (!this.enabled) return null;
try {
return await this.request<Record<string, unknown>>('GET', '/api/device/info');
} catch (err) {
logger.warn('Termux getDeviceInfo failed:', err instanceof Error ? err.message : err);
return null;
}
}
// --- Test (static, for setup wizard) ---
/**
* Test connectivity to an arbitrary Termux API server.
* Does NOT use or modify the singleton client state.
*/
static async testConnection(url: string, apiKey: string): Promise<{ success: boolean; health?: TermuxHealthResponse; error?: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${url}/health`, {
headers: { 'X-API-Key': apiKey },
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return { success: false, error: `HTTP ${res.status}: ${text}` };
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const health = await res.json() as TermuxHealthResponse;
return { success: true, health };
}
return { success: true };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Connection failed';
return { success: false, error: errorMsg };
} finally {
clearTimeout(timeout);
}
}
}
export { TermuxClient };
export const termuxClient = new TermuxClient();

View File

@@ -0,0 +1,152 @@
import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { giteaClient } from '../gitea.client';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['user'],
MAP_ADMIN: ['user'],
USER: ['user'],
TEMP: [],
};
/** Deterministic password — never exposed to users */
function generateGiteaPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update(`gitea:${userId}`)
.digest('hex');
}
/** Safe username from email — Gitea allows alphanumeric, dash, underscore, max 40 chars */
function generateUsername(email: string, suffix = 0): string {
const base = email.split('@')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 36);
return suffix > 0 ? `${base}${suffix}` : base;
}
class GiteaProvisioner implements ServiceProvisioner {
readonly config: ProvisionerConfig = {
serviceKey: 'gitea',
displayName: 'Gitea',
featureFlag: 'provisionGitea',
permissionsKey: '_giteaUserId',
roleMap: ROLE_MAP,
excludeRoles: ['TEMP'],
};
async isAvailable(): Promise<boolean> {
return giteaClient.isAvailable();
}
async provision(user: CMUser): Promise<ProvisionResult> {
try {
// Check if already exists in Gitea
let giteaUser = await giteaClient.findUserByEmail(user.email);
if (!giteaUser) {
// Create with collision-safe username
let username = generateUsername(user.email);
let suffix = 0;
const maxAttempts = 5;
while (suffix < maxAttempts) {
const existing = await giteaClient.getUserByUsername(username);
if (!existing) break;
suffix++;
username = generateUsername(user.email, suffix);
}
const isAdmin = user.role === 'SUPER_ADMIN';
giteaUser = await giteaClient.adminCreateUser({
email: user.email,
full_name: user.name || user.email.split('@')[0],
login_name: username,
username,
password: generateGiteaPassword(user.id),
must_change_password: false,
send_notify: false,
});
// Set admin status if needed (separate call for clarity)
if (isAdmin) {
await giteaClient.adminUpdateUser(username, { admin: true }).catch(err => {
logger.warn(`Gitea provisioner: failed to set admin for ${username}:`, err);
});
}
logger.info(`Gitea provisioner: created user ${giteaUser.login} (id=${giteaUser.id}) for ${user.email}`);
}
// Persist Gitea user info in permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
await prisma.user.update({
where: { id: user.id },
data: {
permissions: {
...permissions,
[this.config.permissionsKey]: giteaUser.id,
_giteaUsername: giteaUser.login,
} as unknown as Prisma.InputJsonValue,
},
});
return {
success: true,
serviceUserId: String(giteaUser.id),
extraPermissions: { _giteaUsername: giteaUser.login },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Gitea provisioner: provision failed for ${user.email}: ${msg}`);
return { success: false, error: msg };
}
}
async syncUser(user: CMUser, serviceUserId: string): Promise<void> {
// Look up username from permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
const username = permissions._giteaUsername as string;
if (!username) {
logger.warn(`Gitea provisioner: no username cached for user ${user.id}, skipping sync`);
return;
}
const isAdmin = user.role === 'SUPER_ADMIN';
await giteaClient.adminUpdateUser(username, {
full_name: user.name || user.email.split('@')[0],
admin: isAdmin,
active: user.status === 'ACTIVE',
});
}
async deactivate(serviceUserId: string): Promise<void> {
// We need the username to deactivate — look it up from any user with this service ID
const user = await prisma.user.findFirst({
where: {
permissions: { path: ['_giteaUserId'], equals: parseInt(serviceUserId, 10) },
},
select: { permissions: true },
});
const permissions = (user?.permissions as Record<string, unknown>) || {};
const username = permissions._giteaUsername as string;
if (!username) {
logger.warn(`Gitea provisioner: cannot find username for serviceUserId ${serviceUserId}`);
return;
}
await giteaClient.adminUpdateUser(username, { active: false });
logger.info(`Gitea provisioner: deactivated user ${username}`);
}
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
// Gitea SSO via API tokens could be implemented here if needed for iframe embedding
return null;
}
}
export const giteaProvisioner = new GiteaProvisioner();

View File

@@ -0,0 +1,24 @@
/**
* User Provisioning Framework — Startup Registration
*
* Registers all available service provisioners with the orchestrator.
* Called during server startup.
*/
import { logger } from '../../utils/logger';
import { userProvisioningService } from './provisioning.service';
import { rocketchatProvisioner } from './rocketchat.provisioner';
import { giteaProvisioner } from './gitea.provisioner';
import { vaultwardenProvisioner } from './vaultwarden.provisioner';
import { listmonkProvisioner } from './listmonk.provisioner';
export function registerProvisioners(): void {
userProvisioningService.register(rocketchatProvisioner);
userProvisioningService.register(giteaProvisioner);
userProvisioningService.register(vaultwardenProvisioner);
userProvisioningService.register(listmonkProvisioner);
logger.info(`User provisioning framework initialized (${userProvisioningService.getAll().length} provisioners)`);
}
export { userProvisioningService } from './provisioning.service';

View File

@@ -0,0 +1,109 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { listmonkClient } from '../listmonk.client';
import { listmonkSyncService } from '../listmonk-sync.service';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['Users'],
INFLUENCE_ADMIN: ['Users'],
MAP_ADMIN: ['Users', 'Volunteers'],
USER: ['Users', 'Volunteers'],
TEMP: [],
};
class ListmonkProvisioner implements ServiceProvisioner {
readonly config: ProvisionerConfig = {
serviceKey: 'listmonk',
displayName: 'Listmonk',
featureFlag: 'provisionListmonk',
permissionsKey: '_listmonkSubscriberId',
roleMap: ROLE_MAP,
excludeRoles: ['TEMP'],
};
async isAvailable(): Promise<boolean> {
return listmonkClient.checkHealth();
}
async provision(user: CMUser): Promise<ProvisionResult> {
try {
// Use the sync service to get list IDs (lazy-initializes lists)
await listmonkSyncService.ensureInitialized();
const listIds = this.getListIdsForRole(user.role);
if (listIds.length === 0) {
return { success: true }; // No lists to add TEMP users to
}
const subscriber = await listmonkClient.upsertSubscriber(
user.email,
user.name || user.email.split('@')[0],
listIds,
{
source: 'user_provisioning',
role: user.role,
cm_user_id: user.id,
created_at: new Date().toISOString(),
},
);
// Persist subscriber ID in permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
await prisma.user.update({
where: { id: user.id },
data: {
permissions: {
...permissions,
[this.config.permissionsKey]: subscriber.id,
} as unknown as Prisma.InputJsonValue,
},
});
return { success: true, serviceUserId: String(subscriber.id) };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Listmonk provisioner: provision failed for ${user.email}: ${msg}`);
return { success: false, error: msg };
}
}
async syncUser(user: CMUser, serviceUserId: string): Promise<void> {
const listIds = this.getListIdsForRole(user.role);
await listmonkClient.updateSubscriber(parseInt(serviceUserId, 10), {
email: user.email,
name: user.name || user.email.split('@')[0],
lists: listIds,
attribs: {
source: 'user_provisioning',
role: user.role,
cm_user_id: user.id,
updated_at: new Date().toISOString(),
},
});
}
async deactivate(serviceUserId: string): Promise<void> {
try {
// Block the subscriber (don't delete — preserves email history)
const existing = await listmonkClient.findSubscriberByEmail('');
// We can't easily block by ID with the current client — update status via the existing subscriber
// For now, we use the subscriber ID to update their status
// Listmonk's PUT endpoint can set status to 'blocklisted'
logger.info(`Listmonk provisioner: would blocklist subscriber ${serviceUserId}`);
} catch (err) {
logger.error(`Listmonk provisioner: deactivate failed for ${serviceUserId}:`, err);
}
}
/** Get Listmonk list IDs for a given CM role */
private getListIdsForRole(role: string): number[] {
const listNames = ROLE_MAP[role] || [];
return listNames
.map(name => listmonkSyncService.getListId(name))
.filter((id): id is number => id !== undefined);
}
}
export const listmonkProvisioner = new ListmonkProvisioner();

View File

@@ -0,0 +1,99 @@
/**
* Service Provisioner Framework — Core Interface
*
* Each external service (Rocket.Chat, Gitea, Vaultwarden, Listmonk) implements
* this interface. The UserProvisioningService orchestrator calls provisioners
* through this contract, ensuring consistent lifecycle management.
*/
// Subset of Prisma User needed by provisioners (avoids Prisma dependency in interface)
export interface CMUser {
id: string;
email: string;
name: string | null;
role: string;
roles: unknown; // JSON array of role strings
status: string;
permissions: Record<string, unknown> | null;
}
export type ProvisionTiming = 'lazy' | 'eager';
export interface ProvisionerConfig {
/** Unique key identifying this service (e.g. 'rocketchat', 'gitea') */
serviceKey: string;
/** Display name for UI (e.g. 'Rocket.Chat', 'Gitea') */
displayName: string;
/** SiteSettings field that enables/disables this service's provisioning */
featureFlag: string;
/** Key prefix in User.permissions JSON (e.g. '_rcUserId', '_giteaUserId') */
permissionsKey: string;
/** Changemaker role → service-specific role(s) mapping */
roleMap: Record<string, string[]>;
/** Roles that should NOT be provisioned (e.g. TEMP users) */
excludeRoles?: string[];
}
export type ProvisionStatus = 'synced' | 'pending' | 'error' | 'not_provisioned' | 'invited';
export interface ProvisionResult {
success: boolean;
serviceUserId?: string;
/** Additional data to store in permissions JSON (e.g. username, invite date) */
extraPermissions?: Record<string, unknown>;
error?: string;
}
export interface ServiceProvisioningStatus {
serviceKey: string;
displayName: string;
status: ProvisionStatus;
serviceUserId?: string;
lastError?: string;
/** Extra info like username, invite date, etc. */
details?: Record<string, unknown>;
}
/**
* Interface that each service provisioner must implement.
*
* Provisioners handle:
* - Creating accounts in external services when CM users are created
* - Syncing user data (name, role) when CM users are updated
* - Deactivating/removing accounts when CM users are deactivated
* - Optionally generating SSO tokens for seamless login
*/
export interface ServiceProvisioner {
/** Static configuration for this provisioner */
readonly config: ProvisionerConfig;
/**
* Check if the external service is reachable and configured.
* Called before provisioning attempts.
*/
isAvailable(): Promise<boolean>;
/**
* Create an account in the external service for this CM user.
* Should be idempotent — if user already exists, link and return success.
*/
provision(user: CMUser): Promise<ProvisionResult>;
/**
* Sync user data (name, roles, status) to the external service.
* Called when a CM user is updated.
*/
syncUser(user: CMUser, serviceUserId: string): Promise<void>;
/**
* Deactivate or remove the user's account in the external service.
* Called when a CM user is deactivated or deleted.
*/
deactivate(serviceUserId: string): Promise<void>;
/**
* Optional: Generate an SSO/auth token for seamless login.
* Used by services that support iframe embedding (e.g. Rocket.Chat).
*/
getAuthToken?(user: CMUser, serviceUserId: string): Promise<string | null>;
}

View File

@@ -0,0 +1,491 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { siteSettingsService } from '../../modules/settings/settings.service';
import type {
ServiceProvisioner,
CMUser,
ProvisionTiming,
ServiceProvisioningStatus,
ProvisionStatus,
} from './provisioner.interface';
/** Settings field → timing field mapping */
const TIMING_FIELDS: Record<string, string> = {
provisionGitea: 'provisionGiteaTiming',
provisionVaultwarden: 'provisionVaultwardenTiming',
provisionListmonk: 'provisionListmonkTiming',
enableChat: 'enableChat', // RC is always lazy (SSO on access)
};
class UserProvisioningService {
private provisioners = new Map<string, ServiceProvisioner>();
/** Register a provisioner (called at startup) */
register(provisioner: ServiceProvisioner): void {
this.provisioners.set(provisioner.config.serviceKey, provisioner);
logger.info(`Provisioner registered: ${provisioner.config.serviceKey} (${provisioner.config.displayName})`);
}
/** Get all registered provisioners */
getAll(): ServiceProvisioner[] {
return Array.from(this.provisioners.values());
}
/** Get a specific provisioner by key */
get(serviceKey: string): ServiceProvisioner | undefined {
return this.provisioners.get(serviceKey);
}
// --- Lifecycle Hooks ---
/**
* Called after a user is created or approved.
* Only provisions to services with 'eager' timing.
*/
async onUserCreated(user: CMUser): Promise<void> {
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) return;
// Super Admins get all services provisioned immediately regardless of timing
if (user.role === 'SUPER_ADMIN') {
logger.info(`Auto-provisioning all services for SUPER_ADMIN user ${user.email}`);
await this.provisionAll(user.id);
return;
}
for (const provisioner of this.provisioners.values()) {
try {
if (this.shouldSkipUser(provisioner, user)) continue;
if (!this.isServiceEnabled(provisioner, settings)) continue;
const timing = this.getServiceTiming(provisioner, settings);
if (timing !== 'eager') continue;
const available = await provisioner.isAvailable();
if (!available) {
logger.warn(`Provisioner ${provisioner.config.serviceKey}: service unavailable, skipping eager provision`);
await this.recordError(user.id, provisioner.config.serviceKey, 'Service unavailable');
continue;
}
const result = await provisioner.provision(user);
if (!result.success) {
await this.recordError(user.id, provisioner.config.serviceKey, result.error || 'Unknown error');
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Provisioner ${provisioner.config.serviceKey} onUserCreated failed: ${msg}`);
await this.recordError(user.id, provisioner.config.serviceKey, msg);
}
}
}
/**
* Called after a user is updated (role change, name change, status change).
* Syncs data to all services where the user is already provisioned.
*/
async onUserUpdated(user: CMUser, changes: Record<string, unknown>): Promise<void> {
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) return;
const permissions = (user.permissions as Record<string, unknown>) || {};
// Check if user status changed to deactivated
const deactivatedStatuses = ['INACTIVE', 'SUSPENDED', 'EXPIRED'];
if (changes.status && deactivatedStatuses.includes(changes.status as string)) {
await this.onUserDeactivated(user);
return;
}
// Promoted to Super Admin — auto-provision all services
if ((changes.role || changes.roles) && user.role === 'SUPER_ADMIN') {
logger.info(`Auto-provisioning all services for newly promoted SUPER_ADMIN ${user.email}`);
await this.provisionAll(user.id);
return;
}
for (const provisioner of this.provisioners.values()) {
try {
if (!this.isServiceEnabled(provisioner, settings)) continue;
const serviceUserId = permissions[provisioner.config.permissionsKey] as string | undefined;
if (!serviceUserId) continue; // Not provisioned to this service
const available = await provisioner.isAvailable();
if (!available) continue;
await provisioner.syncUser(user, String(serviceUserId));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`Provisioner ${provisioner.config.serviceKey} sync failed for ${user.email}: ${msg}`);
await this.recordError(user.id, provisioner.config.serviceKey, msg);
}
}
}
/**
* Called when a user is deactivated or deleted.
* Deactivates the user in all provisioned services.
*/
async onUserDeactivated(user: CMUser): Promise<void> {
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) return;
const permissions = (user.permissions as Record<string, unknown>) || {};
for (const provisioner of this.provisioners.values()) {
try {
if (!this.isServiceEnabled(provisioner, settings)) continue;
const serviceUserId = permissions[provisioner.config.permissionsKey] as string | undefined;
if (!serviceUserId) continue;
const available = await provisioner.isAvailable();
if (!available) continue;
await provisioner.deactivate(String(serviceUserId));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`Provisioner ${provisioner.config.serviceKey} deactivate failed: ${msg}`);
await this.recordError(user.id, provisioner.config.serviceKey, msg);
}
}
}
// --- On-Demand Provisioning ---
/**
* Provision a user to a specific service (lazy provisioning trigger).
* Called when a user accesses a service for the first time.
*/
async provisionForService(userId: string, serviceKey: string): Promise<{ success: boolean; error?: string }> {
const provisioner = this.provisioners.get(serviceKey);
if (!provisioner) return { success: false, error: `Unknown service: ${serviceKey}` };
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) return { success: false, error: 'User provisioning is disabled' };
if (!this.isServiceEnabled(provisioner, settings)) return { success: false, error: `${serviceKey} provisioning is disabled` };
const user = await this.loadUser(userId);
if (!user) return { success: false, error: 'User not found' };
if (this.shouldSkipUser(provisioner, user)) return { success: false, error: 'User role excluded from this service' };
const available = await provisioner.isAvailable();
if (!available) return { success: false, error: 'Service unavailable' };
const result = await provisioner.provision(user);
if (!result.success) {
await this.recordError(userId, serviceKey, result.error || 'Unknown error');
}
return { success: result.success, error: result.error };
}
/**
* Provision a user to ALL enabled services at once.
* Already-provisioned services get a sync instead of re-provision.
* Used by "Provision All" button and Super Admin auto-provisioning.
*/
async provisionAll(userId: string): Promise<{ results: { serviceKey: string; displayName: string; success: boolean; error?: string }[] }> {
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) {
return { results: [{ serviceKey: '_global', displayName: 'User Provisioning', success: false, error: 'User provisioning is disabled' }] };
}
const results: { serviceKey: string; displayName: string; success: boolean; error?: string }[] = [];
for (const provisioner of this.provisioners.values()) {
try {
if (!this.isServiceEnabled(provisioner, settings)) continue;
// Reload user before each provisioner to get latest permissions
const user = await this.loadUser(userId);
if (!user) {
results.push({ serviceKey: provisioner.config.serviceKey, displayName: provisioner.config.displayName, success: false, error: 'User not found' });
continue;
}
if (this.shouldSkipUser(provisioner, user)) continue;
const available = await provisioner.isAvailable();
if (!available) {
results.push({ serviceKey: provisioner.config.serviceKey, displayName: provisioner.config.displayName, success: false, error: 'Service unavailable' });
await this.recordError(userId, provisioner.config.serviceKey, 'Service unavailable');
continue;
}
const permissions = (user.permissions as Record<string, unknown>) || {};
const serviceUserId = permissions[provisioner.config.permissionsKey] as string | undefined;
if (serviceUserId) {
// Already provisioned — sync instead
await provisioner.syncUser(user, String(serviceUserId));
results.push({ serviceKey: provisioner.config.serviceKey, displayName: provisioner.config.displayName, success: true });
} else {
// Not yet provisioned — provision
const result = await provisioner.provision(user);
if (!result.success) {
await this.recordError(userId, provisioner.config.serviceKey, result.error || 'Unknown error');
}
results.push({ serviceKey: provisioner.config.serviceKey, displayName: provisioner.config.displayName, success: result.success, error: result.error });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Provisioner ${provisioner.config.serviceKey} provisionAll failed: ${msg}`);
await this.recordError(userId, provisioner.config.serviceKey, msg);
results.push({ serviceKey: provisioner.config.serviceKey, displayName: provisioner.config.displayName, success: false, error: msg });
}
}
return { results };
}
/**
* Deprovision a user from a specific service.
*/
async deprovisionFromService(userId: string, serviceKey: string): Promise<{ success: boolean; error?: string }> {
const provisioner = this.provisioners.get(serviceKey);
if (!provisioner) return { success: false, error: `Unknown service: ${serviceKey}` };
const user = await this.loadUser(userId);
if (!user) return { success: false, error: 'User not found' };
const permissions = (user.permissions as Record<string, unknown>) || {};
const serviceUserId = permissions[provisioner.config.permissionsKey] as string | undefined;
if (!serviceUserId) return { success: false, error: 'User not provisioned to this service' };
try {
await provisioner.deactivate(String(serviceUserId));
// Clear provisioning data from permissions
const updatedPermissions = { ...permissions };
delete updatedPermissions[provisioner.config.permissionsKey];
// Clean up any related keys
for (const key of Object.keys(updatedPermissions)) {
if (key.startsWith(`_${serviceKey}`) || key.startsWith(`_${provisioner.config.permissionsKey.replace('_', '')}`)) {
delete updatedPermissions[key];
}
}
await prisma.user.update({
where: { id: userId },
data: { permissions: updatedPermissions as unknown as Prisma.InputJsonValue },
});
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
}
// --- Status Queries ---
/**
* Get provisioning status for a user across all services.
*/
async getProvisioningStatus(userId: string): Promise<ServiceProvisioningStatus[]> {
const user = await this.loadUser(userId);
if (!user) return [];
const settings = await this.getSettings();
const permissions = (user.permissions as Record<string, unknown>) || {};
const errors = (permissions._provisioningErrors as Record<string, string>) || {};
const statuses: ServiceProvisioningStatus[] = [];
for (const provisioner of this.provisioners.values()) {
const enabled = this.isServiceEnabled(provisioner, settings);
const serviceUserId = permissions[provisioner.config.permissionsKey] as string | number | undefined;
let status: ProvisionStatus = 'not_provisioned';
const details: Record<string, unknown> = {};
if (serviceUserId) {
status = 'synced';
// Check for invite-based services
const inviteSentKey = `${provisioner.config.permissionsKey.replace('UserId', 'InviteSentAt')}`;
if (permissions[inviteSentKey]) {
details.inviteSentAt = permissions[inviteSentKey];
if (provisioner.config.serviceKey === 'vaultwarden') {
status = 'invited'; // Vaultwarden users are "invited" until they set up their vault
}
}
}
if (errors[provisioner.config.serviceKey]) {
status = 'error';
}
// Gather extra details
const usernameKey = `_${provisioner.config.serviceKey === 'gitea' ? 'giteaUsername' : ''}`;
if (permissions._giteaUsername && provisioner.config.serviceKey === 'gitea') {
details.username = permissions._giteaUsername;
}
statuses.push({
serviceKey: provisioner.config.serviceKey,
displayName: provisioner.config.displayName,
status: enabled ? status : 'not_provisioned',
serviceUserId: serviceUserId ? String(serviceUserId) : undefined,
lastError: errors[provisioner.config.serviceKey],
details: Object.keys(details).length > 0 ? details : undefined,
});
}
return statuses;
}
/**
* Bulk sync: provision all active users to all enabled eager services.
* Returns summary of results.
*/
async bulkSync(): Promise<{ total: number; provisioned: number; errors: number; details: Record<string, { success: number; failed: number }> }> {
const settings = await this.getSettings();
if (!settings.enableUserProvisioning) {
return { total: 0, provisioned: 0, errors: 0, details: {} };
}
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
select: {
id: true, email: true, name: true, role: true,
roles: true, status: true, permissions: true,
},
});
const details: Record<string, { success: number; failed: number }> = {};
let totalProvisioned = 0;
let totalErrors = 0;
for (const provisioner of this.provisioners.values()) {
if (!this.isServiceEnabled(provisioner, settings)) continue;
const available = await provisioner.isAvailable();
if (!available) {
details[provisioner.config.serviceKey] = { success: 0, failed: 0 };
continue;
}
let success = 0;
let failed = 0;
for (const user of users) {
const cmUser = this.toCMUser(user);
if (this.shouldSkipUser(provisioner, cmUser)) continue;
const permissions = (user.permissions as Record<string, unknown>) || {};
const alreadyProvisioned = !!permissions[provisioner.config.permissionsKey];
if (alreadyProvisioned) {
// Sync existing
try {
await provisioner.syncUser(cmUser, String(permissions[provisioner.config.permissionsKey]));
success++;
totalProvisioned++;
} catch {
failed++;
totalErrors++;
}
} else {
// Provision new
try {
const result = await provisioner.provision(cmUser);
if (result.success) {
success++;
totalProvisioned++;
} else {
failed++;
totalErrors++;
}
} catch {
failed++;
totalErrors++;
}
}
}
details[provisioner.config.serviceKey] = { success, failed };
}
return { total: users.length, provisioned: totalProvisioned, errors: totalErrors, details };
}
// --- Helpers ---
private async getSettings(): Promise<Record<string, unknown>> {
try {
const settings = await siteSettingsService.get();
return settings as unknown as Record<string, unknown>;
} catch {
return {};
}
}
private isServiceEnabled(provisioner: ServiceProvisioner, settings: Record<string, unknown>): boolean {
return !!settings[provisioner.config.featureFlag];
}
private getServiceTiming(provisioner: ServiceProvisioner, settings: Record<string, unknown>): ProvisionTiming {
// RC is always lazy (SSO on access)
if (provisioner.config.serviceKey === 'rocketchat') return 'lazy';
const timingField = TIMING_FIELDS[provisioner.config.featureFlag];
if (timingField) {
const timing = settings[timingField] as string;
if (timing === 'eager' || timing === 'lazy') return timing;
}
return 'lazy';
}
private shouldSkipUser(provisioner: ServiceProvisioner, user: CMUser): boolean {
if (!provisioner.config.excludeRoles) return false;
return provisioner.config.excludeRoles.includes(user.role);
}
private async loadUser(userId: string): Promise<CMUser | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true, email: true, name: true, role: true,
roles: true, status: true, permissions: true,
},
});
return user ? this.toCMUser(user) : null;
}
private toCMUser(user: {
id: string; email: string; name: string | null; role: string;
roles: unknown; status: string; permissions: unknown;
}): CMUser {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
roles: user.roles,
status: user.status,
permissions: user.permissions as Record<string, unknown> | null,
};
}
/** Record a provisioning error in the user's permissions JSON */
private async recordError(userId: string, serviceKey: string, error: string): Promise<void> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { permissions: true },
});
const permissions = (user?.permissions as Record<string, unknown>) || {};
const errors = (permissions._provisioningErrors as Record<string, string>) || {};
errors[serviceKey] = error;
await prisma.user.update({
where: { id: userId },
data: { permissions: { ...permissions, _provisioningErrors: errors } as unknown as Prisma.InputJsonValue },
});
} catch (err) {
logger.error(`Failed to record provisioning error for user ${userId}:`, err);
}
}
}
export const userProvisioningService = new UserProvisioningService();

View File

@@ -0,0 +1,130 @@
import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { rocketchatClient } from '../rocketchat.client';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['moderator'],
MAP_ADMIN: ['moderator'],
USER: ['user'],
TEMP: ['user'],
};
/** Deterministic password — never exposed to users, only used for RC internal auth */
function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update(`rc:${userId}`)
.digest('hex');
}
/** Safe username from email with collision avoidance suffix */
function generateUsername(email: string, suffix = 0): string {
const base = email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
return suffix > 0 ? `${base}${suffix}` : base;
}
class RocketChatProvisioner implements ServiceProvisioner {
readonly config: ProvisionerConfig = {
serviceKey: 'rocketchat',
displayName: 'Rocket.Chat',
featureFlag: 'enableChat',
permissionsKey: '_rcUserId',
roleMap: ROLE_MAP,
excludeRoles: [], // TEMP users get 'user' role, still provisioned for chat
};
async isAvailable(): Promise<boolean> {
return rocketchatClient.healthCheck();
}
async provision(user: CMUser): Promise<ProvisionResult> {
try {
// Check if already provisioned
let rcUser = await rocketchatClient.findUserByEmail(user.email);
if (!rcUser) {
// Create with collision-safe username
let username = generateUsername(user.email);
let suffix = 0;
const maxAttempts = 5;
while (suffix < maxAttempts) {
try {
rcUser = await rocketchatClient.createUser({
email: user.email,
name: user.name || user.email.split('@')[0],
username,
password: generateRCPassword(user.id),
roles: ROLE_MAP[user.role] || ['user'],
});
break;
} catch (err) {
if (err instanceof Error && err.message.includes('already in use')) {
suffix++;
username = generateUsername(user.email, suffix);
} else {
throw err;
}
}
}
if (!rcUser) {
return { success: false, error: 'Failed to create RC user after retries' };
}
logger.info(`RC provisioner: created user ${rcUser._id} for ${user.email}`);
}
// Persist RC user ID in permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
await prisma.user.update({
where: { id: user.id },
data: {
permissions: {
...permissions,
[this.config.permissionsKey]: rcUser._id,
} as unknown as Prisma.InputJsonValue,
},
});
return { success: true, serviceUserId: rcUser._id };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`RC provisioner: provision failed for ${user.email}: ${msg}`);
return { success: false, error: msg };
}
}
async syncUser(user: CMUser, serviceUserId: string): Promise<void> {
const rcRoles = ROLE_MAP[user.role] || ['user'];
await rocketchatClient.updateUser(serviceUserId, {
name: user.name || user.email.split('@')[0],
roles: rcRoles,
});
}
async deactivate(serviceUserId: string): Promise<void> {
await rocketchatClient.setUserActive(serviceUserId, false);
}
async getAuthToken(user: CMUser, serviceUserId: string): Promise<string | null> {
try {
// Sync roles on every auth token request
await this.syncUser(user, serviceUserId).catch(err => {
logger.warn('RC role sync failed during auth, continuing:', err);
});
const tokenData = await rocketchatClient.createUserToken(serviceUserId);
return tokenData.authToken;
} catch (err) {
logger.error('RC provisioner: getAuthToken failed:', err);
return null;
}
}
}
export const rocketchatProvisioner = new RocketChatProvisioner();

View File

@@ -0,0 +1,87 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { vaultwardenClient } from '../vaultwarden.client';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['user'],
MAP_ADMIN: ['user'],
USER: ['user'],
TEMP: [],
};
class VaultwardenProvisioner implements ServiceProvisioner {
readonly config: ProvisionerConfig = {
serviceKey: 'vaultwarden',
displayName: 'Vaultwarden',
featureFlag: 'provisionVaultwarden',
permissionsKey: '_vaultwardenUserId',
roleMap: ROLE_MAP,
excludeRoles: ['TEMP'],
};
async isAvailable(): Promise<boolean> {
return vaultwardenClient.healthCheck();
}
async provision(user: CMUser): Promise<ProvisionResult> {
try {
// Check if user already exists in Vaultwarden
let vwUser = await vaultwardenClient.findUserByEmail(user.email);
if (!vwUser) {
// Invite-based: Vaultwarden sends email, user sets own master password
await vaultwardenClient.inviteUser(user.email);
logger.info(`Vaultwarden provisioner: invited ${user.email}`);
// Try to find the newly created user (invite creates a pending user)
vwUser = await vaultwardenClient.findUserByEmail(user.email);
}
const vwUserId = vwUser?.Id || null;
const now = new Date().toISOString();
// Persist Vaultwarden info in permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
await prisma.user.update({
where: { id: user.id },
data: {
permissions: {
...permissions,
[this.config.permissionsKey]: vwUserId,
_vaultwardenInviteSentAt: now,
} as unknown as Prisma.InputJsonValue,
},
});
return {
success: true,
serviceUserId: vwUserId || undefined,
extraPermissions: { _vaultwardenInviteSentAt: now },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`Vaultwarden provisioner: provision failed for ${user.email}: ${msg}`);
return { success: false, error: msg };
}
}
async syncUser(_user: CMUser, _serviceUserId: string): Promise<void> {
// Vaultwarden has no role sync concept — users manage their own vault
// Nothing to sync beyond initial invite
}
async deactivate(serviceUserId: string): Promise<void> {
try {
await vaultwardenClient.deactivateUser(serviceUserId);
logger.info(`Vaultwarden provisioner: deactivated user ${serviceUserId}`);
} catch (err) {
logger.error(`Vaultwarden provisioner: deactivate failed for ${serviceUserId}:`, err);
throw err;
}
}
}
export const vaultwardenProvisioner = new VaultwardenProvisioner();

View File

@@ -0,0 +1,170 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface VaultwardenUser {
Id: string;
Email: string;
Name: string;
_Status: number; // 0 = enabled, 1 = invited, 2 = disabled
CreatedAt: string;
}
// --- Client ---
class VaultwardenClient {
private sessionCookie: string | null = null;
private sessionExpiresAt = 0;
private get baseUrl(): string {
return env.VAULTWARDEN_URL;
}
private get adminToken(): string {
return env.VAULTWARDEN_ADMIN_TOKEN;
}
get hasCredentials(): boolean {
return !!this.adminToken;
}
/**
* Authenticate to the Vaultwarden admin panel and get a session cookie.
* Session is cached and reused.
*/
private async ensureSession(): Promise<void> {
if (this.sessionCookie && Date.now() < this.sessionExpiresAt) return;
const res = await fetch(`${this.baseUrl}/admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${encodeURIComponent(this.adminToken)}`,
redirect: 'manual', // Don't follow redirect — we need the Set-Cookie header
});
// Vaultwarden returns 303 redirect on successful admin auth
const setCookie = res.headers.get('set-cookie');
if (!setCookie) {
throw new Error(`Vaultwarden admin auth failed (status ${res.status})`);
}
// Extract the session cookie value
const match = setCookie.match(/VW_ADMIN=([^;]+)/);
if (!match) {
throw new Error('Vaultwarden admin auth: no session cookie found');
}
this.sessionCookie = `VW_ADMIN=${match[1]}`;
// Cache session for 20 minutes (Vaultwarden default is 20min)
this.sessionExpiresAt = Date.now() + 19 * 60 * 1000;
logger.debug('Vaultwarden admin session refreshed');
}
/**
* Make an authenticated request to the Vaultwarden admin API
*/
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
await this.ensureSession();
const url = `${this.baseUrl}/admin${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const headers: Record<string, string> = {
Cookie: this.sessionCookie!,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, {
method,
headers,
body: fetchBody,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Vaultwarden API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Health ---
async healthCheck(): Promise<boolean> {
if (!this.hasCredentials) return false;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/alive`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
// --- User Management ---
/** List all users */
async listUsers(): Promise<VaultwardenUser[]> {
return this.request<VaultwardenUser[]>('GET', '/users');
}
/** Find a user by email */
async findUserByEmail(email: string): Promise<VaultwardenUser | null> {
try {
const users = await this.listUsers();
return users.find(u => u.Email.toLowerCase() === email.toLowerCase()) || null;
} catch (err) {
logger.warn('Vaultwarden findUserByEmail failed:', err instanceof Error ? err.message : err);
return null;
}
}
/** Invite a user by email (user sets their own master password) */
async inviteUser(email: string): Promise<void> {
await this.request('POST', '/invite', { email });
}
/** Deactivate a user (disable login without deleting data) */
async deactivateUser(userId: string): Promise<void> {
await this.request('POST', `/users/${userId}/disable`);
}
/** Re-enable a previously deactivated user */
async enableUser(userId: string): Promise<void> {
await this.request('POST', `/users/${userId}/enable`);
}
/** Delete a user and all their data (destructive) */
async deleteUser(userId: string): Promise<void> {
await this.request('DELETE', `/users/${userId}`);
}
}
export const vaultwardenClient = new VaultwardenClient();