sms updates

This commit is contained in:
2026-02-27 15:02:28 -07:00
parent 9f9244df32
commit 06ce9dac1b
22 changed files with 1983 additions and 211 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "sms_shift_reminder_hours" INTEGER NOT NULL DEFAULT 24,
ADD COLUMN "sms_shift_reminders" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sms_shift_signup_confirm" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sms_volunteer_welcome" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -916,6 +916,12 @@ model SiteSettings {
notifyVolunteerShiftReminder Boolean @default(true)
notifyVolunteerShiftThankYou Boolean @default(true)
// SMS notification settings
smsShiftReminders Boolean @default(false) @map("sms_shift_reminders")
smsShiftReminderHours Int @default(24) @map("sms_shift_reminder_hours")
smsShiftSignupConfirm Boolean @default(false) @map("sms_shift_signup_confirm")
smsVolunteerWelcome Boolean @default(false) @map("sms_volunteer_welcome")
// Re-engagement settings
notifyVolunteerReengagement Boolean @default(false) @map("notify_volunteer_reengagement")
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")

View File

@@ -464,6 +464,9 @@ async function main() {
console.warn('⚠️ No admin user found - skipping email template seeding');
}
// Seed SMS notification templates
await seedSmsNotificationTemplates();
// Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds();
@@ -852,6 +855,56 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
}
/**
* Seed default SMS notification templates
*/
async function seedSmsNotificationTemplates() {
console.log('Seeding SMS notification templates...');
const templates = [
{
name: 'shift-reminder',
template: 'Hi {name}, reminder: {shiftTitle} is tomorrow at {shiftTime}. Location: {shiftLocation}',
description: 'Sent before a volunteer shift as a reminder',
category: 'notification',
},
{
name: 'shift-signup-confirm',
template: "Hi {name}, you're signed up for {shiftTitle} on {shiftDate} at {shiftTime}. See you there!",
description: 'Sent when a volunteer signs up for a shift',
category: 'notification',
},
{
name: 'volunteer-welcome',
template: 'Welcome to the team, {name}! Thanks for signing up as a volunteer.',
description: 'Sent when a new volunteer account is created',
category: 'notification',
},
];
let seeded = 0;
let skipped = 0;
for (const t of templates) {
const existing = await prisma.smsMessageTemplate.findFirst({ where: { name: t.name } });
if (existing) {
skipped++;
continue;
}
await prisma.smsMessageTemplate.create({
data: {
name: t.name,
template: t.template,
description: t.description,
category: t.category,
},
});
seeded++;
}
console.log(`SMS notification templates seeded: ${seeded} created, ${skipped} skipped`);
}
/**
* Seed pre-made gallery ads
*/

View File

@@ -16,6 +16,7 @@ import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service';
import { generateSlug } from '../../../utils/slug';
import { siteSettingsService } from '../../settings/settings.service';
import { smsNotificationService } from '../../../services/sms-notification.service';
import crypto from 'crypto';
import type {
CreateShiftInput,
@@ -537,6 +538,23 @@ export const shiftsService = {
logger.error('Failed to send shift signup confirmation email:', err);
}
// SMS signup confirmation (fire-and-forget)
if (data.phone) {
const shiftDate = new Date(shift.date);
const smsDateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
smsNotificationService.sendShiftSignupConfirmation(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftDate: smsDateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
}).catch(err => logger.error('SMS signup confirmation failed:', err));
}
// Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftSignup({
@@ -595,6 +613,20 @@ export const shiftsService = {
logger.error('Failed to schedule shift reminder:', err);
}
// SMS shift reminder (fire-and-forget, delay calculated by notification service)
if (data.phone) {
const smsShiftDatetime = new Date(shift.date);
const [smsH, smsM] = shift.startTime.split(':').map(Number);
smsShiftDatetime.setHours(smsH || 0, smsM || 0, 0, 0);
smsNotificationService.sendShiftReminder(data.phone, {
name: data.name,
shiftTitle: shift.title,
shiftTime: shift.startTime,
shiftLocation: shift.location || 'TBD',
}, smsShiftDatetime).catch(err => logger.error('SMS shift reminder failed:', err));
}
// Notification: schedule post-shift thank-you (2h after end)
try {
if (await isNotificationEnabled('notifyVolunteerShiftThankYou')) {

View File

@@ -30,6 +30,63 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
} catch (err) { next(err); }
});
// --- Database Import Previews (must be BEFORE /:id routes) ---
// GET /api/sms/contacts/preview-users — preview users with phone numbers
router.get('/preview-users', async (req, res, next) => {
try {
const result = await smsContactsService.previewUsers({
role: req.query.role as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-addresses — preview addresses with phone numbers
router.get('/preview-addresses', async (req, res, next) => {
try {
const result = await smsContactsService.previewAddresses({
province: req.query.province as string | undefined,
supportLevel: req.query.supportLevel as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-signups — preview shift signups with phone numbers
router.get('/preview-signups', async (req, res, next) => {
try {
const result = await smsContactsService.previewSignups({
shiftId: req.query.shiftId as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-conversations — preview conversations
router.get('/preview-conversations', async (req, res, next) => {
try {
const result = await smsContactsService.previewConversations({
campaignId: req.query.campaignId as string | undefined,
responseType: req.query.responseType as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/contacts/preview-contacts — preview CRM contacts with phones
router.get('/preview-contacts', async (req, res, next) => {
try {
const result = await smsContactsService.previewContacts({
tag: req.query.tag as string | undefined,
supportLevel: req.query.supportLevel as string | undefined,
});
res.json(result);
} catch (err) { next(err); }
});
// --- Single List Routes (/:id) ---
// GET /api/sms/contacts/:id — get a single contact list
router.get('/:id', async (req, res, next) => {
try {
@@ -106,4 +163,51 @@ router.post('/:id/import-phone', async (req, res, next) => {
} catch (err) { next(err); }
});
// --- Database Import Routes ---
// POST /api/sms/contacts/:id/import-users — import users with phone numbers
router.post('/:id/import-users', async (req, res, next) => {
try {
const { role } = req.body as { role?: string };
const result = await smsContactsService.importFromUsers(req.params.id as string, { role });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-addresses — import addresses with phone numbers
router.post('/:id/import-addresses', async (req, res, next) => {
try {
const { province, supportLevel } = req.body as { province?: string; supportLevel?: string };
const result = await smsContactsService.importFromAddresses(req.params.id as string, { province, supportLevel });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-signups — import shift signups with phone numbers
router.post('/:id/import-signups', async (req, res, next) => {
try {
const { shiftId } = req.body as { shiftId?: string };
const result = await smsContactsService.importFromSignups(req.params.id as string, { shiftId });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-conversations — import from conversations
router.post('/:id/import-conversations', async (req, res, next) => {
try {
const { campaignId, responseType } = req.body as { campaignId?: string; responseType?: string };
const result = await smsContactsService.importFromConversations(req.params.id as string, { campaignId, responseType });
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/import-contacts — import CRM contacts with phones
router.post('/:id/import-contacts', async (req, res, next) => {
try {
const { tag, supportLevel } = req.body as { tag?: string; supportLevel?: string };
const result = await smsContactsService.importFromContacts(req.params.id as string, { tag, supportLevel });
res.json(result);
} catch (err) { next(err); }
});
export const smsContactsRouter = router;

View File

@@ -1,5 +1,5 @@
import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { Prisma, type UserRole, type SupportLevel, type SmsResponseType } from '@prisma/client';
import { termuxClient } from '../../../services/termux.client';
import type { CreateContactListInput, UpdateContactListInput, CreateContactEntryInput } from './sms-contacts.schemas';
@@ -291,4 +291,332 @@ export const smsContactsService = {
const count = await prisma.smsContactListEntry.count({ where: { listId } });
return { totalEntries: count, duplicatesRemoved: 0 };
},
// =========================================================================
// Database Import Sources — preview + import methods
// =========================================================================
/**
* Preview users with phone numbers matching filters
*/
async previewUsers(filters: { role?: string }) {
const where: Prisma.UserWhereInput = { phone: { not: null } };
if (filters.role) where.role = filters.role as UserRole;
const [total, sample] = await Promise.all([
prisma.user.count({ where }),
prisma.user.findMany({
where,
select: { phone: true, name: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((u) => ({ phone: u.phone!, name: u.name || undefined })),
};
},
/**
* Import users with phone numbers into a contact list
*/
async importFromUsers(listId: string, filters: { role?: string }) {
const where: Prisma.UserWhereInput = { phone: { not: null } };
if (filters.role) where.role = filters.role as UserRole;
const users = await prisma.user.findMany({
where,
select: { phone: true, name: true, email: true },
});
let imported = 0;
let skipped = 0;
for (const user of users) {
const phone = normalizePhone(user.phone!);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: user.name || undefined, email: user.email || undefined },
update: { name: user.name || undefined, email: user.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview addresses with phone numbers matching filters
*/
async previewAddresses(filters: { province?: string; supportLevel?: string }) {
const where: Prisma.AddressWhereInput = { phone: { not: null } };
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.province) where.location = { province: filters.province };
const [total, sample] = await Promise.all([
prisma.address.count({ where }),
prisma.address.findMany({
where,
select: { phone: true, firstName: true, lastName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((a) => ({
phone: a.phone!,
name: [a.firstName, a.lastName].filter(Boolean).join(' ') || undefined,
})),
};
},
/**
* Import addresses with phone numbers into a contact list
*/
async importFromAddresses(listId: string, filters: { province?: string; supportLevel?: string }) {
const where: Prisma.AddressWhereInput = { phone: { not: null } };
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.province) where.location = { province: filters.province };
const addresses = await prisma.address.findMany({
where,
select: { phone: true, firstName: true, lastName: true, email: true },
});
let imported = 0;
let skipped = 0;
for (const addr of addresses) {
const phone = normalizePhone(addr.phone!);
if (!phone) { skipped++; continue; }
const name = [addr.firstName, addr.lastName].filter(Boolean).join(' ') || undefined;
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name, email: addr.email || undefined },
update: { name, email: addr.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview shift signups with phone numbers matching filters
*/
async previewSignups(filters: { shiftId?: string }) {
const where: Prisma.ShiftSignupWhereInput = { userPhone: { not: null } };
if (filters.shiftId) where.shiftId = filters.shiftId;
const [total, sample] = await Promise.all([
prisma.shiftSignup.count({ where }),
prisma.shiftSignup.findMany({
where,
select: { userPhone: true, userName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((s) => ({ phone: s.userPhone!, name: s.userName || undefined })),
};
},
/**
* Import shift signups with phone numbers into a contact list
*/
async importFromSignups(listId: string, filters: { shiftId?: string }) {
const where: Prisma.ShiftSignupWhereInput = { userPhone: { not: null } };
if (filters.shiftId) where.shiftId = filters.shiftId;
const signups = await prisma.shiftSignup.findMany({
where,
select: { userPhone: true, userName: true, userEmail: true },
});
let imported = 0;
let skipped = 0;
for (const signup of signups) {
const phone = normalizePhone(signup.userPhone!);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: signup.userName || undefined, email: signup.userEmail || undefined },
update: { name: signup.userName || undefined, email: signup.userEmail || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview conversations matching filters (excluding opted-out)
*/
async previewConversations(filters: { campaignId?: string; responseType?: string }) {
const where: Prisma.SmsConversationWhereInput = { status: { not: 'OPTED_OUT' } };
if (filters.campaignId) where.campaignId = filters.campaignId;
if (filters.responseType) {
where.messages = { some: { responseType: filters.responseType as SmsResponseType } };
}
const [total, sample] = await Promise.all([
prisma.smsConversation.count({ where }),
prisma.smsConversation.findMany({
where,
select: { phone: true, contactName: true },
take: 10,
}),
]);
return {
total,
sample: sample.map((c) => ({ phone: c.phone, name: c.contactName || undefined })),
};
},
/**
* Import conversation contacts into a contact list (excluding opted-out)
*/
async importFromConversations(listId: string, filters: { campaignId?: string; responseType?: string }) {
const where: Prisma.SmsConversationWhereInput = { status: { not: 'OPTED_OUT' } };
if (filters.campaignId) where.campaignId = filters.campaignId;
if (filters.responseType) {
where.messages = { some: { responseType: filters.responseType as SmsResponseType } };
}
const conversations = await prisma.smsConversation.findMany({
where,
select: { phone: true, contactName: true },
});
let imported = 0;
let skipped = 0;
for (const conv of conversations) {
const phone = normalizePhone(conv.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: conv.contactName || undefined },
update: { name: conv.contactName || undefined },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
/**
* Preview CRM contacts with phones matching filters
*/
async previewContacts(filters: { tag?: string; supportLevel?: string }) {
const where: Prisma.ContactWhereInput = {
phones: { some: {} },
mergedIntoId: null,
doNotContact: false,
smsOptOut: false,
};
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.tag) where.tags = { array_contains: [filters.tag] };
const [total, sample] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
select: { displayName: true, phones: { select: { phone: true }, take: 1 } },
take: 10,
}),
]);
return {
total,
sample: sample.map((c) => ({
phone: c.phones[0]?.phone || '',
name: c.displayName || undefined,
})),
};
},
/**
* Import CRM contacts with phones into a contact list
*/
async importFromContacts(listId: string, filters: { tag?: string; supportLevel?: string }) {
const where: Prisma.ContactWhereInput = {
phones: { some: {} },
mergedIntoId: null,
doNotContact: false,
smsOptOut: false,
};
if (filters.supportLevel) where.supportLevel = filters.supportLevel as SupportLevel;
if (filters.tag) where.tags = { array_contains: [filters.tag] };
const contacts = await prisma.contact.findMany({
where,
select: { displayName: true, email: true, phones: { select: { phone: true } } },
});
let imported = 0;
let skipped = 0;
for (const contact of contacts) {
for (const cp of contact.phones) {
const phone = normalizePhone(cp.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: contact.displayName || undefined, email: contact.email || undefined },
update: { name: contact.displayName || undefined, email: contact.email || undefined },
});
imported++;
} catch {
skipped++;
}
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
};

View File

@@ -80,4 +80,32 @@ router.post('/:id/reply', async (req, res, next) => {
} catch (err) { next(err); }
});
// --- Bulk Actions ---
// POST /api/sms/conversations/bulk-read — mark multiple conversations as read
router.post('/bulk-read', async (req, res, next) => {
try {
const { ids } = req.body as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const result = await smsConversationsService.bulkMarkRead(ids);
res.json(result);
} catch (err) { next(err); }
});
// POST /api/sms/conversations/bulk-close — close multiple conversations
router.post('/bulk-close', async (req, res, next) => {
try {
const { ids } = req.body as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const result = await smsConversationsService.bulkClose(ids);
res.json(result);
} catch (err) { next(err); }
});
export const smsConversationsRouter = router;

View File

@@ -46,6 +46,7 @@ export const smsConversationsService = {
where: { id },
include: {
campaign: { select: { id: true, name: true } },
contact: { select: { id: true, displayName: true } },
messages: {
orderBy: { sentAt: 'asc' },
take: 200,
@@ -136,4 +137,38 @@ export const smsConversationsService = {
]);
return { total, active, optedOut, unread };
},
/**
* Bulk mark conversations as read
*/
async bulkMarkRead(ids: string[]) {
if (ids.length === 0) return { updated: 0 };
const result = await prisma.smsConversation.updateMany({
where: { id: { in: ids } },
data: { unreadCount: 0 },
});
// Also mark all messages in these conversations as read
await prisma.smsMessage.updateMany({
where: { conversationId: { in: ids }, isRead: false },
data: { isRead: true },
});
return { updated: result.count };
},
/**
* Bulk close conversations
*/
async bulkClose(ids: string[]) {
if (ids.length === 0) return { updated: 0 };
const result = await prisma.smsConversation.updateMany({
where: { id: { in: ids }, status: 'ACTIVE' },
data: { status: 'CLOSED' },
});
return { updated: result.count };
},
};

View File

@@ -2,6 +2,7 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service';
import { termuxClient } from '../../../services/termux.client';
const router = Router();
@@ -40,4 +41,17 @@ router.post('/sync', async (_req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/device/logs — tail phone API server logs
router.get('/logs', async (req, res, next) => {
try {
const lines = Math.min(500, Math.max(1, Number(req.query.lines) || 100));
const logs = await termuxClient.getLogs(lines);
if (!logs) {
res.status(503).json({ error: 'Phone not connected or SMS not enabled' });
return;
}
res.json(logs);
} catch (err) { next(err); }
});
export const smsDeviceRouter = router;

View File

@@ -0,0 +1,157 @@
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { smsQueueService } from './sms-queue.service';
class SmsNotificationService {
/**
* Send a shift reminder SMS.
* Respects smsShiftReminders setting and calculates appropriate delay.
*/
async sendShiftReminder(
phone: string,
data: { name: string; shiftTitle: string; shiftTime: string; shiftLocation: string },
shiftDatetime: Date,
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsShiftReminders) return;
const template = await this.getTemplate('shift-reminder');
if (!template) return;
const message = this.substituteTemplate(template, {
name: data.name,
shiftTitle: data.shiftTitle,
shiftTime: data.shiftTime,
shiftLocation: data.shiftLocation,
});
// Calculate delay: send N hours before the shift
const reminderHours = settings.smsShiftReminderHours || 24;
const sendAt = new Date(shiftDatetime.getTime() - reminderHours * 60 * 60 * 1000);
const delayMs = Math.max(0, sendAt.getTime() - Date.now());
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
delayMs,
);
}
/**
* Send a shift signup confirmation SMS. Sent immediately.
*/
async sendShiftSignupConfirmation(
phone: string,
data: { name: string; shiftTitle: string; shiftDate: string; shiftTime: string },
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsShiftSignupConfirm) return;
const template = await this.getTemplate('shift-signup-confirm');
if (!template) return;
const message = this.substituteTemplate(template, {
name: data.name,
shiftTitle: data.shiftTitle,
shiftDate: data.shiftDate,
shiftTime: data.shiftTime,
});
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
/**
* Send a welcome SMS to a new volunteer. Sent immediately.
*/
async sendVolunteerWelcome(
phone: string,
data: { name: string },
): Promise<void> {
if (!(await this.shouldSend(phone))) return;
const settings = await this.getSettings();
if (!settings.smsVolunteerWelcome) return;
const template = await this.getTemplate('volunteer-welcome');
if (!template) return;
const message = this.substituteTemplate(template, { name: data.name });
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
/**
* Send a custom one-off SMS notification. Sent immediately.
*/
async sendCustom(phone: string, message: string): Promise<void> {
if (!(await this.shouldSend(phone))) return;
await smsQueueService.addSmsJob(
{ recipientId: '', campaignId: '', phone, message, attemptNumber: 1 },
);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Check whether we should actually send an SMS to this phone.
* Verifies: enableSms is on, phone is non-empty, phone is not opted out.
*/
private async shouldSend(phone: string): Promise<boolean> {
if (!phone || !phone.trim()) return false;
const settings = await this.getSettings();
if (!settings.enableSms) return false;
// Check for opt-out: if a conversation exists with status OPTED_OUT, skip
const optedOut = await prisma.smsConversation.findFirst({
where: { phone, status: 'OPTED_OUT' },
select: { id: true },
});
if (optedOut) {
logger.debug(`SMS notification skipped for ${phone}: opted out`);
return false;
}
return true;
}
/**
* Look up an SmsMessageTemplate by name.
*/
private async getTemplate(name: string): Promise<string | null> {
const tmpl = await prisma.smsMessageTemplate.findFirst({
where: { name },
select: { template: true },
});
return tmpl?.template || null;
}
/**
* Replace {var} placeholders in a template string.
*/
private substituteTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
return vars[key] !== undefined ? vars[key] : match;
});
}
/**
* Fetch site settings (cached per-request is fine for notification context).
*/
private async getSettings() {
const { siteSettingsService } = await import('../modules/settings/settings.service');
return siteSettingsService.get();
}
}
export const smsNotificationService = new SmsNotificationService();

View File

@@ -5,9 +5,9 @@ import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { termuxClient } from './termux.client';
interface SmsJobData {
recipientId: string;
campaignId: string;
export interface SmsJobData {
recipientId: string; // empty string for notification jobs
campaignId: string; // empty string for notification jobs
phone: string;
message: string;
attemptNumber: number;
@@ -34,17 +34,21 @@ class SmsQueueService {
'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}`);
const isNotification = !campaignId;
// Check if campaign is still RUNNING (support pause)
const campaign = await prisma.smsCampaign.findUnique({
where: { id: campaignId },
select: { status: true },
});
logger.info(`Processing SMS job ${job.id}${isNotification ? ' (notification)' : ` for campaign ${campaignId}`}, phone ${phone}`);
if (!campaign || campaign.status !== 'RUNNING') {
logger.info(`Campaign ${campaignId} is ${campaign?.status || 'deleted'}, skipping SMS to ${phone}`);
return { skipped: true, reason: 'campaign_not_running' };
// For campaign jobs, check if campaign is still RUNNING (support pause)
if (!isNotification) {
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
@@ -52,15 +56,17 @@ class SmsQueueService {
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,
},
});
// Update recipient status (campaign jobs only)
if (!isNotification && recipientId) {
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({
@@ -70,64 +76,71 @@ class SmsQueueService {
direction: 'OUTBOUND',
status,
connectionType: 'termux',
campaignId,
campaignId: campaignId || null,
},
});
// 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(),
},
});
// Create or update conversation (campaign jobs use compound unique, notifications use phone-only)
if (!isNotification) {
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 },
});
// 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,
// 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);
});
} 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 } },
});
// 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}`);
}
} else {
await prisma.smsCampaign.update({
where: { id: campaignId },
data: { totalFailed: { increment: 1 } },
});
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
// Notification job: just throw on failure for BullMQ retry
if (!result.success) {
throw new Error(`Failed to send notification SMS to ${phone}: ${result.error}`);
}
}
return { success: true, phone };

View File

@@ -76,7 +76,7 @@ class TermuxClient {
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);
logger.warn(`Failed to load SMS config from DB: ${err instanceof Error ? err.message : err}`);
}
}
@@ -152,7 +152,7 @@ class TermuxClient {
try {
return await this.request<TermuxHealthResponse>('GET', '/health');
} catch (err) {
logger.warn('Termux getHealth failed:', err instanceof Error ? err.message : err);
logger.warn(`Termux getHealth failed: ${err instanceof Error ? err.message : err}`);
return null;
}
}
@@ -171,7 +171,7 @@ class TermuxClient {
}, 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);
logger.warn(`Termux sendSms to ${phone} failed: ${errorMsg}`);
return { success: false, error: errorMsg };
}
}
@@ -193,7 +193,7 @@ class TermuxClient {
);
return data.messages || [];
} catch (err) {
logger.warn('Termux getInbox failed:', err instanceof Error ? err.message : err);
logger.warn(`Termux getInbox failed: ${err instanceof Error ? err.message : err}`);
return [];
}
}
@@ -211,7 +211,7 @@ class TermuxClient {
);
return data.contacts || [];
} catch (err) {
logger.warn('Termux getContacts failed:', err instanceof Error ? err.message : err);
logger.warn(`Termux getContacts failed: ${err instanceof Error ? err.message : err}`);
return [];
}
}
@@ -228,7 +228,7 @@ class TermuxClient {
);
return data.battery || data as unknown as TermuxBatteryStatus;
} catch (err) {
logger.warn('Termux getBattery failed:', err instanceof Error ? err.message : err);
logger.warn(`Termux getBattery failed: ${err instanceof Error ? err.message : err}`);
return null;
}
}
@@ -239,7 +239,33 @@ class TermuxClient {
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);
logger.warn(`Termux getDeviceInfo failed: ${err instanceof Error ? err.message : err}`);
return null;
}
}
// --- Logs ---
/**
* Get last N lines from the phone's SMS API log file.
*/
async getLogs(lines = 100): Promise<{ lines: string[]; totalLines: number; fileSize: number } | null> {
if (!this.enabled) return null;
try {
const data = await this.request<{ lines?: string[]; total_lines?: number; file_size?: number; success?: boolean }>(
'GET',
`/api/logs/tail?lines=${Math.min(500, Math.max(1, lines))}`,
undefined,
15000,
);
return {
lines: data.lines || [],
totalLines: data.total_lines || 0,
fileSize: data.file_size || 0,
};
} catch (err) {
logger.warn(`Termux getLogs failed: ${err instanceof Error ? err.message : err}`);
return null;
}
}