Okay Wish I could say I know exactly. Will do better next time promise lol
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
437
api/src/services/gancio-settings-sync.service.ts
Normal file
437
api/src/services/gancio-settings-sync.service.ts
Normal 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, '<').replace(/>/g, '>');
|
||||
|
||||
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();
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
604
api/src/services/gitea.client.ts
Normal file
604
api/src/services/gitea.client.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
197
api/src/services/reengagement.service.ts
Normal file
197
api/src/services/reengagement.service.ts
Normal 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();
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
68
api/src/services/sms-device-monitor.service.ts
Normal file
68
api/src/services/sms-device-monitor.service.ts
Normal 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();
|
||||
245
api/src/services/sms-queue.service.ts
Normal file
245
api/src/services/sms-queue.service.ts
Normal 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();
|
||||
207
api/src/services/sms-response-sync.service.ts
Normal file
207
api/src/services/sms-response-sync.service.ts
Normal 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();
|
||||
211
api/src/services/social-digest.service.ts
Normal file
211
api/src/services/social-digest.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
127
api/src/services/tailscale.client.ts
Normal file
127
api/src/services/tailscale.client.ts
Normal 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();
|
||||
281
api/src/services/termux.client.ts
Normal file
281
api/src/services/termux.client.ts
Normal 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();
|
||||
152
api/src/services/user-provisioning/gitea.provisioner.ts
Normal file
152
api/src/services/user-provisioning/gitea.provisioner.ts
Normal 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();
|
||||
24
api/src/services/user-provisioning/index.ts
Normal file
24
api/src/services/user-provisioning/index.ts
Normal 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';
|
||||
109
api/src/services/user-provisioning/listmonk.provisioner.ts
Normal file
109
api/src/services/user-provisioning/listmonk.provisioner.ts
Normal 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();
|
||||
99
api/src/services/user-provisioning/provisioner.interface.ts
Normal file
99
api/src/services/user-provisioning/provisioner.interface.ts
Normal 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>;
|
||||
}
|
||||
491
api/src/services/user-provisioning/provisioning.service.ts
Normal file
491
api/src/services/user-provisioning/provisioning.service.ts
Normal 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();
|
||||
130
api/src/services/user-provisioning/rocketchat.provisioner.ts
Normal file
130
api/src/services/user-provisioning/rocketchat.provisioner.ts
Normal 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();
|
||||
@@ -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();
|
||||
170
api/src/services/vaultwarden.client.ts
Normal file
170
api/src/services/vaultwarden.client.ts
Normal 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();
|
||||
Reference in New Issue
Block a user