sms updates
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
157
api/src/services/sms-notification.service.ts
Normal file
157
api/src/services/sms-notification.service.ts
Normal 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();
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user