Initial BizOne portal setup

This commit is contained in:
2026-05-11 11:36:33 +07:00
commit 57017dd397
249 changed files with 41305 additions and 0 deletions

37
backend/src/app.module.ts Normal file
View File

@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { ContactsModule } from './contacts/contacts.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { PrismaModule } from './prisma/prisma.module';
import { JobsModule } from './jobs/jobs.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { IntegrationsModule } from './integrations/integrations.module';
import { HealthModule } from './health/health.module';
import { LogsModule } from './logs/logs.module';
import { RolesModule } from './roles/roles.module';
import { MailerModule } from './mailer/mailer.module';
import { UsersModule } from './users/users.module';
import { CampaignsModule } from './campaigns/campaigns.module';
import { ConversationsModule } from './conversations/conversations.module';
import { TemplatesModule } from './templates/templates.module';
@Module({
imports: [
PrismaModule,
JobsModule,
MailerModule,
AuthModule,
ContactsModule,
WebhooksModule,
DashboardModule,
IntegrationsModule,
HealthModule,
LogsModule,
RolesModule,
UsersModule,
TemplatesModule,
CampaignsModule,
ConversationsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,130 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { AuthGuard } from '../common/auth.guard';
import { AuthenticatedUser } from './auth.types';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { LogoutDto } from './dto/logout.dto';
import { RequestPasswordResetDto } from './dto/request-password-reset.dto';
import { CompletePasswordResetDto } from './dto/complete-password-reset.dto';
import { TwoFactorCodeDto } from './dto/two-factor-code.dto';
import { VerifyTwoFactorLoginDto } from './dto/verify-two-factor-login.dto';
@Controller('auth')
export class AuthController {
private readonly authService: AuthService;
constructor(authService: AuthService) {
this.authService = authService;
}
@Post('login')
signIn(@Req() request: Request, @Body() body: LoginDto) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.login(body.email, body.password, ipAddress);
}
@Post('refresh')
refresh(@Req() request: Request, @Body() body: RefreshTokenDto) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.refresh(body.refreshToken, ipAddress);
}
@Post('logout')
signOut(@Req() request: Request, @Body() body: LogoutDto) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
const authHeader = request.headers.authorization;
const token =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length).trim()
: undefined;
return this.authService.logout({ accessToken: token, refreshToken: body.refreshToken, ipAddress });
}
@Post('forgot-password')
forgotPassword(@Req() request: Request, @Body() body: RequestPasswordResetDto) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.requestPasswordReset(body.email, ipAddress);
}
@Get('password-reset/:token')
getPasswordReset(@Param('token') token: string) {
return this.authService.getPasswordResetToken(token);
}
@Post('password-reset/:token')
completePasswordReset(
@Req() request: Request,
@Param('token') token: string,
@Body() body: CompletePasswordResetDto,
) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.completePasswordReset(token, body.password, ipAddress);
}
@UseGuards(AuthGuard)
@Get('me')
getMe(@Req() request: Request & { user: AuthenticatedUser }) {
return this.authService.me(request.user.sub);
}
@UseGuards(AuthGuard)
@Get('session')
getCurrentSession(@Req() request: Request & { user: AuthenticatedUser }) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.getCurrentSession(request.user, ipAddress);
}
@UseGuards(AuthGuard)
@Get('2fa/status')
getTwoFactorStatus(@Req() request: Request & { user: AuthenticatedUser }) {
return this.authService.getTwoFactorStatus(request.user.sub);
}
@UseGuards(AuthGuard)
@Post('2fa/setup/initiate')
initiateTwoFactorSetup(@Req() request: Request & { user: AuthenticatedUser }) {
return this.authService.initiateTwoFactorSetup(request.user.sub);
}
@UseGuards(AuthGuard)
@Post('2fa/setup/confirm')
confirmTwoFactorSetup(
@Req() request: Request & { user: AuthenticatedUser },
@Body() body: TwoFactorCodeDto,
) {
return this.authService.confirmTwoFactorSetup(request.user.sub, body.code, request.ip);
}
@UseGuards(AuthGuard)
@Post('2fa/disable')
disableTwoFactor(
@Req() request: Request & { user: AuthenticatedUser },
@Body() body: TwoFactorCodeDto,
) {
return this.authService.disableTwoFactor(request.user.sub, body.code, request.ip);
}
@UseGuards(AuthGuard)
@Post('2fa/recovery-codes/regenerate')
regenerateTwoFactorRecoveryCodes(
@Req() request: Request & { user: AuthenticatedUser },
@Body() body: TwoFactorCodeDto,
) {
return this.authService.regenerateTwoFactorRecoveryCodes(request.user.sub, body.code, request.ip);
}
@Post('2fa/login/verify')
verifyTwoFactorLogin(@Req() request: Request, @Body() body: VerifyTwoFactorLoginDto) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.verifyTwoFactorLogin(body.challengeToken, body.code, ipAddress);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { getAppConfig } from '../config/env';
import { MailerModule } from '../mailer/mailer.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
const config = getAppConfig();
@Module({
imports: [
MailerModule,
JwtModule.register({
secret: config.jwtSecret,
signOptions: { expiresIn: config.jwtExpiresIn },
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [JwtModule, AuthService],
})
export class AuthModule {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
export type AuthenticatedUser = {
sub: string;
email: string;
ver: number;
iat?: number;
exp?: number;
};

View File

@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class CompletePasswordResetDto {
@IsString()
@MinLength(8)
password!: string;
}

View File

@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsEmail()
email!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(8)
password!: string;
}

View File

@ -0,0 +1,10 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class LogoutDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(16)
refreshToken?: string;
}

View File

@ -0,0 +1,9 @@
import { Transform } from 'class-transformer';
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class RequestPasswordResetDto {
@IsEmail()
email!: string;
}

View File

@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class TwoFactorCodeDto {
@IsString()
@Matches(/^\d{6}$/)
code!: string;
}

View File

@ -0,0 +1,10 @@
import { IsString, Matches } from 'class-validator';
export class VerifyTwoFactorLoginDto {
@IsString()
challengeToken!: string;
@IsString()
@Matches(/^(\d{6}|[A-Za-z0-9]{4}-?[A-Za-z0-9]{4})$/)
code!: string;
}

131
backend/src/auth/totp.ts Normal file
View File

@ -0,0 +1,131 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buffer: Buffer) {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function base32Decode(value: string) {
const normalized = value.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, '');
let bits = 0;
let current = 0;
const output: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) {
continue;
}
current = (current << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((current >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(output);
}
function generateHotp(secret: string, counter: number, digits = 6) {
const key = base32Decode(secret);
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buffer.writeUInt32BE(counter >>> 0, 4);
const hmac = createHmac('sha1', key).update(buffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const binary =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
const otp = binary % 10 ** digits;
return otp.toString().padStart(digits, '0');
}
export function generateTotpSecret() {
return base32Encode(randomBytes(20));
}
export function verifyTotpCode(secret: string, code: string, window = 1, timestamp = Date.now()) {
const normalized = code.replace(/\s+/g, '');
if (!/^\d{6}$/.test(normalized)) {
return false;
}
for (let offset = -window; offset <= window; offset += 1) {
const counter = Math.floor((timestamp + offset * 30_000) / 1000 / 30);
const candidate = generateHotp(secret, counter);
const left = Buffer.from(candidate);
const right = Buffer.from(normalized);
if (left.length === right.length && timingSafeEqual(left, right)) {
return true;
}
}
return false;
}
export function buildOtpAuthUrl(secret: string, email: string, issuer: string) {
const label = encodeURIComponent(`${issuer}:${email}`);
const params = new URLSearchParams({
secret,
issuer,
algorithm: 'SHA1',
digits: '6',
period: '30',
});
return `otpauth://totp/${label}?${params.toString()}`;
}
function deriveEncryptionKey(secret: string) {
return createHash('sha256').update(`${secret}:totp`).digest();
}
export function encryptSecret(secret: string, masterSecret: string) {
const key = deriveEncryptionKey(masterSecret);
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
}
export function decryptSecret(payload: string, masterSecret: string) {
const [ivPart, tagPart, encryptedPart] = payload.split('.');
if (!ivPart || !tagPart || !encryptedPart) {
throw new Error('Invalid encrypted secret payload');
}
const key = deriveEncryptionKey(masterSecret);
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64url'));
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedPart, 'base64url')),
decipher.final(),
]);
return decrypted.toString('utf8');
}

View File

@ -0,0 +1,42 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { Job as BullJob, Worker } from 'bullmq';
import { JobsService } from '../jobs/jobs.service';
import { RedisQueueService } from '../jobs/redis-queue.service';
import { CampaignsService } from './campaigns.service';
@Injectable()
export class CampaignWorkerService implements OnModuleInit, OnModuleDestroy {
private worker: Worker | null = null;
constructor(
private readonly jobsService: JobsService,
private readonly redisQueueService: RedisQueueService,
private readonly campaignsService: CampaignsService,
) {}
onModuleInit() {
this.worker = this.redisQueueService.createWorker(
'campaigns',
async (job: BullJob<{ dbJobId?: string }>) => {
const dbJobId = job.data?.dbJobId;
if (!dbJobId) {
throw new Error('Redis campaign job missing dbJobId');
}
const claimed = await this.jobsService.markProcessing(dbJobId);
if (!claimed) {
return;
}
await this.campaignsService.processJob(dbJobId);
},
);
}
async onModuleDestroy() {
if (this.worker) {
await this.worker.close();
this.worker = null;
}
}
}

View File

@ -0,0 +1,103 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { RequirePermission } from '../common/permission.decorator';
import { PermissionGuard } from '../common/permission.guard';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { SendCampaignDto } from './dto/send-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
import { CampaignsService } from './campaigns.service';
@UseGuards(AuthGuard, PermissionGuard)
@Controller('campaigns')
export class CampaignsController {
constructor(private readonly campaignsService: CampaignsService) {}
@Get()
@RequirePermission('campaigns', 'view')
findAll() {
return this.campaignsService.findAll();
}
@Post()
@RequirePermission('campaigns', 'edit')
create(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateCampaignDto,
) {
return this.campaignsService.create(dto, request.user, request.ip);
}
@Get(':id')
@RequirePermission('campaigns', 'view')
findOne(
@Param('id') id: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.campaignsService.findOne(
id,
page ? Number(page) : 1,
limit ? Number(limit) : 5,
);
}
@Post(':id/duplicate')
@RequirePermission('campaigns', 'edit')
duplicate(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
) {
return this.campaignsService.duplicate(id, request.user, request.ip);
}
@Patch(':id')
@RequirePermission('campaigns', 'edit')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateCampaignDto,
) {
return this.campaignsService.update(id, dto, request.user, request.ip);
}
@Delete(':id')
@RequirePermission('campaigns', 'delete')
remove(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
) {
return this.campaignsService.remove(id, request.user, request.ip);
}
@Post(':id/send')
@RequirePermission('campaigns', 'manage')
send(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: SendCampaignDto,
) {
return this.campaignsService.send(id, dto, request.user, request.ip);
}
@Get(':id/export')
@RequirePermission('campaigns', 'view')
async export(
@Req() request: Request & { user: AuthenticatedUser },
@Res() response: Response,
@Param('id') id: string,
@Query('format') format?: string,
) {
const result = await this.campaignsService.exportReport(
id,
format === 'xlsx' ? 'xlsx' : 'csv',
request.user,
request.ip,
);
response.setHeader('Content-Type', result.contentType);
response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`);
response.send(result.buffer);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { JobsModule } from '../jobs/jobs.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TemplatesModule } from '../templates/templates.module';
import { CampaignWorkerService } from './campaign-worker.service';
import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service';
@Module({
imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule],
controllers: [CampaignsController],
providers: [CampaignsService, CampaignWorkerService],
exports: [CampaignsService],
})
export class CampaignsModule {}

View File

@ -0,0 +1,966 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Campaign, CampaignRecipient, Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as XLSX from 'xlsx';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeText } from '../common/normalize';
import { JobsService } from '../jobs/jobs.service';
import { PrismaService } from '../prisma/prisma.service';
import { TemplatesService } from '../templates/templates.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { SendCampaignDto } from './dto/send-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
type SeedCampaign = {
code: string;
name: string;
audienceLabel: string;
audienceGroup: string;
status: string;
totalRecipients: number;
deliveredCount: number;
readCount: number;
failedCount: number;
deliveryRate: number | null;
readRate: number | null;
sentAt?: Date;
scheduledAt?: Date;
templateName: string;
language: string;
messageTitle: string;
messageBody: string;
primaryButton: string;
secondaryButton: string;
bannerImageUrl: string;
recipients: Array<{
phoneNumber: string;
status: string;
sentAt?: Date;
errorReason?: string | null;
deviceOs?: string | null;
}>;
};
const summerSaleSentAt = new Date('2024-07-15T09:45:00.000Z');
const seededCampaigns: SeedCampaign[] = [
{
code: 'CAM-98231',
name: 'Summer Sale 2024',
audienceLabel: '45,200 recipients',
audienceGroup: 'Retail subscribers',
status: 'Sent',
totalRecipients: 48250,
deliveredCount: 47482,
readCount: 30987,
failedCount: 578,
deliveryRate: 98.4,
readRate: 64.2,
sentAt: summerSaleSentAt,
templateName: 'summer_promo_v2',
language: 'English (US)',
messageTitle: 'Hi {{name}}, ☀️',
messageBody:
'Our Summer Sale is here! Get up to 40% OFF on all new arrivals. Use code SUMMER40 at checkout.',
primaryButton: 'Shop Collection',
secondaryButton: 'View Catalog',
bannerImageUrl:
'https://lh3.googleusercontent.com/aida-public/AB6AXuDEStTHrI49NhOpgRMdXx3saVUtVNe9fBtTvDiMZMeuDcQNU8eJHfAxc5hS5M8ligofVNNpUi59-kOLD9peg5njH1bWmsrHGXIx7A37_pAFEfxEAGVbjVjWCD0mGWIHu4LIShS9yDlFmvznUPzlye_JNLPzs7S8LIULMi-bL7cP6qt-uzXKuoWUwj1sw0uq0UcJnkCb6Y-04pNG8iNd2MINCbLOSbmRyf8OSOe1b9-u-sA6p5Mq3CKRjP-Fvk0vk3ZKdritVLiB0U8',
recipients: [
{ phoneNumber: '+1 (555) 012-3456', status: 'Read', sentAt: new Date('2024-07-15T10:12:00.000Z'), deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 012-7890', status: 'Delivered', sentAt: new Date('2024-07-15T09:48:00.000Z'), deviceOs: 'iOS' },
{ phoneNumber: '+1 (555) 013-1122', status: 'Failed', sentAt: new Date('2024-07-15T09:45:00.000Z'), errorReason: 'Policy Violation', deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 014-3344', status: 'Read', sentAt: new Date('2024-07-15T10:05:00.000Z'), deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 015-5566', status: 'Delivered', sentAt: new Date('2024-07-15T09:52:00.000Z'), deviceOs: 'iOS' },
{ phoneNumber: '+1 (555) 016-7788', status: 'Read', sentAt: new Date('2024-07-15T10:16:00.000Z'), deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 017-8899', status: 'Delivered', sentAt: new Date('2024-07-15T10:22:00.000Z'), deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 018-9911', status: 'Read', sentAt: new Date('2024-07-15T10:31:00.000Z'), deviceOs: 'iOS' },
{ phoneNumber: '+1 (555) 019-2200', status: 'Failed', sentAt: new Date('2024-07-15T10:42:00.000Z'), errorReason: 'Invalid Template Parameter', deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 020-3311', status: 'Delivered', sentAt: new Date('2024-07-15T10:51:00.000Z'), deviceOs: 'Web/Desktop' },
{ phoneNumber: '+1 (555) 021-4422', status: 'Read', sentAt: new Date('2024-07-15T11:09:00.000Z'), deviceOs: 'Android' },
{ phoneNumber: '+1 (555) 022-5533', status: 'Read', sentAt: new Date('2024-07-15T11:18:00.000Z'), deviceOs: 'iOS' },
],
},
{
code: 'CAM-98244',
name: 'Weekly Newsletter #42',
audienceLabel: 'VIP Customer List',
audienceGroup: 'High-value segment',
status: 'Scheduled',
totalRecipients: 18640,
deliveredCount: 0,
readCount: 0,
failedCount: 0,
deliveryRate: null,
readRate: null,
scheduledAt: new Date('2024-07-16T14:00:00.000Z'),
templateName: 'vip_newsletter_v42',
language: 'English (US)',
messageTitle: 'Your insider update is almost here',
messageBody: 'A curated digest for premium customers with fresh offers and product stories.',
primaryButton: 'Open Newsletter',
secondaryButton: 'Manage Preferences',
bannerImageUrl:
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80',
recipients: [],
},
{
code: 'CAM-98255',
name: 'Product Launch Promo',
audienceLabel: 'New Leads Segment',
audienceGroup: 'Cold outreach',
status: 'Draft',
totalRecipients: 9300,
deliveredCount: 0,
readCount: 0,
failedCount: 0,
deliveryRate: 0,
readRate: 0,
templateName: 'launch_teaser_v1',
language: 'English (US)',
messageTitle: 'Be first to see the launch',
messageBody: 'A teaser sequence for high-intent prospects before the public product announcement.',
primaryButton: 'Join Waitlist',
secondaryButton: 'See Features',
bannerImageUrl:
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=800&q=80',
recipients: [],
},
{
code: 'CAM-98201',
name: 'Loyalty Program Update',
audienceLabel: 'Dormant Users',
audienceGroup: 'Reactivation list',
status: 'Failed',
totalRecipients: 60700,
deliveredCount: 27315,
readCount: 10844,
failedCount: 33385,
deliveryRate: 45,
readRate: 17.9,
sentAt: new Date('2024-07-10T08:30:00.000Z'),
templateName: 'loyalty_reactivation_v1',
language: 'English (US)',
messageTitle: 'We saved your rewards for you',
messageBody: 'A recovery campaign for inactive users with points reminder and welcome-back offer.',
primaryButton: 'Claim Rewards',
secondaryButton: 'Need Help',
bannerImageUrl:
'https://images.unsplash.com/photo-1515169067868-5387ec356754?auto=format&fit=crop&w=800&q=80',
recipients: [],
},
];
@Injectable()
export class CampaignsService {
constructor(
private readonly prisma: PrismaService,
private readonly jobsService: JobsService,
private readonly templatesService: TemplatesService,
) {}
async findAll() {
await this.ensureSeedData();
const campaigns = await this.prisma.campaign.findMany();
campaigns.sort((left, right) => this.getSortScore(right) - this.getSortScore(left));
const totalMessages = campaigns.reduce((sum, campaign) => sum + campaign.totalRecipients, 0);
const averageDeliveryRate = this.average(
campaigns
.map((campaign) => campaign.deliveryRate)
.filter((value): value is number => value !== null),
);
const scheduledCount = campaigns.filter((campaign) => campaign.status === 'Scheduled').length;
const failedDeliveries = campaigns.reduce((sum, campaign) => sum + campaign.failedCount, 0);
return {
metrics: {
totalMessages,
averageDeliveryRate,
scheduledCount,
failedDeliveries,
},
items: campaigns.map((campaign) => this.serializeCampaignRow(campaign)),
};
}
async findOne(id: string, page = 1, limit = 5) {
await this.ensureSeedData();
const take = Math.min(Math.max(limit, 1), 50);
const currentPage = Math.max(page, 1);
const campaign = await this.prisma.campaign.findUnique({
where: { id },
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
const recipientWhere: Prisma.CampaignRecipientWhereInput = {
campaignId: campaign.id,
};
const [recipients, totalRecipients, deviceBreakdown] = await Promise.all([
this.prisma.campaignRecipient.findMany({
where: recipientWhere,
orderBy: [{ sentAt: 'asc' }, { createdAt: 'asc' }],
skip: (currentPage - 1) * take,
take,
}),
this.prisma.campaignRecipient.count({ where: recipientWhere }),
this.prisma.campaignRecipient.groupBy({
by: ['deviceOs'],
where: recipientWhere,
_count: { _all: true },
}),
]);
return {
campaign: this.serializeCampaignDetail(campaign),
timeline: this.buildTimeline(campaign, recipients),
recipients: {
items: recipients.map((recipient) => this.serializeRecipient(recipient)),
total: totalRecipients,
page: currentPage,
pageSize: take,
totalPages: Math.max(1, Math.ceil(totalRecipients / take)),
},
deviceBreakdown: this.buildDeviceBreakdown(deviceBreakdown, totalRecipients),
};
}
async create(dto: CreateCampaignDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
await this.templatesService.assertTemplateExistsByName(dto.templateName);
const actor = await this.findActor(user.sub, user.email);
const name = normalizeText(dto.name) || 'Untitled Campaign';
const totalRecipients = Math.max(0, dto.totalRecipients);
const status = normalizeText(dto.status) || 'Draft';
const campaign = await this.prisma.campaign.create({
data: {
id: randomUUID(),
code: await this.generateCampaignCode(),
name,
audienceLabel: normalizeText(dto.audienceLabel) || 'Untitled Segment',
audienceGroup: normalizeText(dto.audienceGroup) || 'General audience',
status,
totalRecipients,
deliveredCount: 0,
readCount: 0,
failedCount: 0,
deliveryRate: status === 'Draft' ? 0 : null,
readRate: 0,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
templateName: normalizeText(dto.templateName) || 'new_campaign_template',
language: normalizeText(dto.language) || 'English (US)',
messageTitle: normalizeText(dto.messageTitle) || 'New campaign draft',
messageBody: normalizeText(dto.messageBody) || 'Add your WhatsApp message content here.',
primaryButton: normalizeText(dto.primaryButton) || 'Primary CTA',
secondaryButton: normalizeText(dto.secondaryButton) || 'Secondary CTA',
bannerImageUrl:
normalizeText(dto.bannerImageUrl) ||
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80',
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Campaign Created',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: `Created campaign ${campaign.name} (${campaign.code}).`,
},
});
return this.serializeCampaignRow(campaign);
}
async duplicate(id: string, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
const actor = await this.findActor(user.sub, user.email);
const source = await this.prisma.campaign.findUnique({
where: { id },
include: {
recipients: true,
},
});
if (!source) {
throw new NotFoundException('Campaign not found');
}
const duplicate = await this.prisma.campaign.create({
data: {
id: randomUUID(),
code: await this.generateCampaignCode(),
name: `${source.name} Copy`,
audienceLabel: source.audienceLabel,
audienceGroup: source.audienceGroup,
status: 'Draft',
totalRecipients: source.totalRecipients,
deliveredCount: 0,
readCount: 0,
failedCount: 0,
deliveryRate: 0,
readRate: 0,
scheduledAt: null,
templateName: source.templateName,
language: source.language,
messageTitle: source.messageTitle,
messageBody: source.messageBody,
primaryButton: source.primaryButton,
secondaryButton: source.secondaryButton,
bannerImageUrl: source.bannerImageUrl,
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Campaign Duplicated',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: `Duplicated campaign ${source.name} into ${duplicate.name}.`,
},
});
return {
id: duplicate.id,
code: duplicate.code,
name: duplicate.name,
status: duplicate.status,
};
}
async update(id: string, dto: UpdateCampaignDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
await this.templatesService.assertTemplateExistsByName(dto.templateName);
const actor = await this.findActor(user.sub, user.email);
const campaign = await this.prisma.campaign.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: normalizeText(dto.name) || 'Untitled Campaign' } : {}),
...(dto.audienceLabel !== undefined ? { audienceLabel: normalizeText(dto.audienceLabel) || 'Untitled Segment' } : {}),
...(dto.audienceGroup !== undefined ? { audienceGroup: normalizeText(dto.audienceGroup) || 'General audience' } : {}),
...(dto.status !== undefined ? { status: normalizeText(dto.status) || 'Draft' } : {}),
...(dto.totalRecipients !== undefined ? { totalRecipients: Math.max(0, dto.totalRecipients) } : {}),
...(dto.scheduledAt !== undefined ? { scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null } : {}),
...(dto.templateName !== undefined ? { templateName: normalizeText(dto.templateName) || 'new_campaign_template' } : {}),
...(dto.language !== undefined ? { language: normalizeText(dto.language) || 'English (US)' } : {}),
...(dto.messageTitle !== undefined ? { messageTitle: normalizeText(dto.messageTitle) || 'New campaign draft' } : {}),
...(dto.messageBody !== undefined ? { messageBody: normalizeText(dto.messageBody) || 'Add your WhatsApp message content here.' } : {}),
...(dto.primaryButton !== undefined ? { primaryButton: normalizeText(dto.primaryButton) || 'Primary CTA' } : {}),
...(dto.secondaryButton !== undefined ? { secondaryButton: normalizeText(dto.secondaryButton) || 'Secondary CTA' } : {}),
...(dto.bannerImageUrl !== undefined
? {
bannerImageUrl:
normalizeText(dto.bannerImageUrl) ||
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=800&q=80',
}
: {}),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Campaign Updated',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated campaign ${campaign.name} (${campaign.code}).`,
},
});
return this.serializeCampaignRow(campaign);
}
async remove(id: string, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
const actor = await this.findActor(user.sub, user.email);
const campaign = await this.prisma.campaign.delete({
where: { id },
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Campaign Deleted',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: `Deleted campaign ${campaign.name} (${campaign.code}).`,
},
});
return { id: campaign.id, deleted: true };
}
async send(id: string, dto: SendCampaignDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
const actor = await this.findActor(user.sub, user.email);
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
const requestedMode = dto.mode === 'scheduled' ? 'scheduled' : 'now';
const requestedScheduleAt = dto.scheduledAt
? new Date(dto.scheduledAt)
: campaign.scheduledAt;
const shouldSchedule =
requestedMode === 'scheduled' || Boolean(requestedScheduleAt && requestedScheduleAt.getTime() > Date.now());
const availableAt =
shouldSchedule && requestedScheduleAt && requestedScheduleAt.getTime() > Date.now()
? requestedScheduleAt
: new Date();
await this.prisma.campaign.update({
where: { id },
data: {
status: shouldSchedule ? 'Scheduled' : 'Sending',
scheduledAt: shouldSchedule ? availableAt : null,
sentAt: shouldSchedule ? null : new Date(),
},
});
const job = await this.jobsService.enqueue({
queueName: 'campaigns',
jobType: 'campaign.dispatch',
payload: { campaignId: id },
maxAttempts: 3,
availableAt,
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: shouldSchedule ? 'Campaign Scheduled' : 'Campaign Send Queued',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: shouldSchedule
? `Scheduled campaign ${campaign.name} for ${availableAt.toISOString()}.`
: `Queued campaign ${campaign.name} for immediate dispatch.`,
metadataJson: {
campaignId: id,
jobId: job.id,
availableAt: availableAt.toISOString(),
} as Prisma.InputJsonValue,
},
});
return {
id: campaign.id,
jobId: job.id,
status: shouldSchedule ? 'Scheduled' : 'Queued',
availableAt: availableAt.toISOString(),
};
}
async exportReport(
id: string,
format: 'csv' | 'xlsx',
user: AuthenticatedUser,
ipAddress?: string,
) {
await this.ensureSeedData();
const actor = await this.findActor(user.sub, user.email);
const campaign = await this.prisma.campaign.findUnique({
where: { id },
include: {
recipients: {
orderBy: [{ sentAt: 'asc' }, { createdAt: 'asc' }],
},
},
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
const summaryRows = [
['Campaign Name', campaign.name],
['Campaign Code', campaign.code],
['Status', campaign.status],
['Total Recipients', campaign.totalRecipients],
['Delivered Count', campaign.deliveredCount],
['Delivered Rate', campaign.deliveryRate ?? 0],
['Read Count', campaign.readCount],
['Read Rate', campaign.readRate ?? 0],
['Failed Count', campaign.failedCount],
['Template Name', campaign.templateName || ''],
['Language', campaign.language || ''],
];
const recipientRows = campaign.recipients.map((recipient) => ({
phoneNumber: recipient.phoneNumber,
status: recipient.status,
timestamp: recipient.sentAt?.toISOString() || '',
errorReason: recipient.errorReason || '',
deviceOs: recipient.deviceOs || '',
}));
const baseName = this.slugifyFileName(`${campaign.name}-${campaign.code}`);
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Campaign Report Exported',
module: 'Campaigns',
ipAddress: ipAddress || null,
severity: 'default',
details: `Exported ${format.toUpperCase()} report for campaign ${campaign.name}.`,
},
});
if (format === 'csv') {
const lines = [
['Section', 'Key', 'Value'].join(','),
...summaryRows.map(([key, value]) => ['Summary', key, value].map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(',')),
'',
['Phone Number', 'Status', 'Timestamp', 'Error Reason', 'Device OS'].join(','),
...recipientRows.map((row) =>
[row.phoneNumber, row.status, row.timestamp, row.errorReason, row.deviceOs]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
.join(','),
),
].join('\n');
return {
fileName: `${baseName}.csv`,
contentType: 'text/csv; charset=utf-8',
buffer: Buffer.from(lines, 'utf8'),
};
}
const workbook = XLSX.utils.book_new();
const summarySheet = XLSX.utils.aoa_to_sheet([['Metric', 'Value'], ...summaryRows]);
const recipientsSheet = XLSX.utils.json_to_sheet(recipientRows);
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Summary');
XLSX.utils.book_append_sheet(workbook, recipientsSheet, 'Recipients');
return {
fileName: `${baseName}.xlsx`,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }),
};
}
async processJob(jobId: string) {
const job = await this.jobsService.findById(jobId);
if (!job) {
return;
}
try {
const payload = job.payloadJson as { campaignId?: string };
const campaignId = payload?.campaignId;
if (!campaignId) {
throw new Error('Campaign job payload is missing campaignId');
}
await this.dispatchCampaign(campaignId);
await this.jobsService.complete(jobId);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown campaign processing error';
if (job.attempts < job.maxAttempts) {
await this.jobsService.retry(jobId, message, 2000 * job.attempts);
} else {
await this.jobsService.fail(jobId, message);
}
}
}
private async ensureSeedData() {
const existingCodes = new Set(
(
await this.prisma.campaign.findMany({
select: { code: true },
})
).map((campaign) => campaign.code),
);
for (const seed of seededCampaigns) {
if (existingCodes.has(seed.code)) {
continue;
}
await this.prisma.campaign.create({
data: {
id: randomUUID(),
code: seed.code,
name: seed.name,
audienceLabel: seed.audienceLabel,
audienceGroup: seed.audienceGroup,
status: seed.status,
totalRecipients: seed.totalRecipients,
deliveredCount: seed.deliveredCount,
readCount: seed.readCount,
failedCount: seed.failedCount,
deliveryRate: seed.deliveryRate,
readRate: seed.readRate,
sentAt: seed.sentAt,
scheduledAt: seed.scheduledAt,
templateName: seed.templateName,
language: seed.language,
messageTitle: seed.messageTitle,
messageBody: seed.messageBody,
primaryButton: seed.primaryButton,
secondaryButton: seed.secondaryButton,
bannerImageUrl: seed.bannerImageUrl,
recipients: {
create: seed.recipients.map((recipient) => ({
id: randomUUID(),
phoneNumber: recipient.phoneNumber,
status: recipient.status,
sentAt: recipient.sentAt,
errorReason: recipient.errorReason || null,
deviceOs: recipient.deviceOs || null,
})),
},
},
});
}
}
private serializeCampaignRow(campaign: Campaign) {
return {
id: campaign.id,
code: campaign.code,
name: campaign.name,
audience: campaign.audienceLabel,
audienceGroup: campaign.audienceGroup,
sent: campaign.totalRecipients.toLocaleString('en-US'),
opened: campaign.readCount.toLocaleString('en-US'),
status: campaign.status,
deliveryRate: campaign.deliveryRate,
dateLabel: this.getDateLabel(campaign),
timeLabel: this.getTimeLabel(campaign),
templateName: campaign.templateName,
};
}
private serializeCampaignDetail(campaign: Campaign) {
return {
id: campaign.id,
code: campaign.code,
name: campaign.name,
status: campaign.status,
initiatedAt: campaign.sentAt?.toISOString() || campaign.scheduledAt?.toISOString() || campaign.createdAt.toISOString(),
totalRecipients: campaign.totalRecipients,
deliveredCount: campaign.deliveredCount,
deliveredRate: campaign.deliveryRate || 0,
readCount: campaign.readCount,
readRate: campaign.readRate || 0,
failedCount: campaign.failedCount,
failedRate:
campaign.totalRecipients > 0 ? Number(((campaign.failedCount / campaign.totalRecipients) * 100).toFixed(1)) : 0,
templateName: campaign.templateName || '-',
language: campaign.language || '-',
messageTitle: campaign.messageTitle || '',
messageBody: campaign.messageBody || '',
primaryButton: campaign.primaryButton || '',
secondaryButton: campaign.secondaryButton || '',
bannerImageUrl: campaign.bannerImageUrl || '',
};
}
private serializeRecipient(recipient: CampaignRecipient) {
return {
id: recipient.id,
phoneNumber: recipient.phoneNumber,
status: recipient.status,
sentAt: recipient.sentAt?.toISOString() || null,
errorReason: recipient.errorReason || null,
deviceOs: recipient.deviceOs || null,
};
}
private buildTimeline(campaign: Campaign, recipients: CampaignRecipient[]) {
const latestActivity = recipients.reduce<Date>(
(latest, recipient) =>
recipient.sentAt && recipient.sentAt.getTime() > latest.getTime() ? recipient.sentAt : latest,
campaign.sentAt || campaign.createdAt,
);
const timelineEnd = new Date(latestActivity);
timelineEnd.setUTCMinutes(0, 0, 0);
timelineEnd.setUTCHours(timelineEnd.getUTCHours() + 1);
const buckets = Array.from({ length: 12 }, (_, index) => {
const start = new Date(timelineEnd);
start.setUTCHours(timelineEnd.getUTCHours() - (11 - index) * 2);
const bucketEnd = new Date(start);
bucketEnd.setUTCHours(start.getUTCHours() + 2);
const count = recipients.filter((recipient) => {
if (!recipient.sentAt) {
return false;
}
return recipient.sentAt >= start && recipient.sentAt < bucketEnd;
}).length;
return {
label: start.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
}),
count,
};
});
const highest = Math.max(...buckets.map((bucket) => bucket.count), 1);
return buckets.map((bucket) => ({
label: bucket.label,
count: bucket.count,
height: Math.max(10, Math.round((bucket.count / highest) * 100)),
}));
}
private buildDeviceBreakdown(
breakdown: Array<{ deviceOs: string | null; _count: { _all: number } }>,
total: number,
) {
const totalSafe = total || 1;
const normalized = ['Android', 'iOS', 'Web/Desktop'].map((label) => {
const match = breakdown.find((entry) => entry.deviceOs === label);
const count = match?._count._all || 0;
return {
label,
count,
percentage: Math.round((count / totalSafe) * 100),
};
});
return normalized;
}
private getDateLabel(campaign: Campaign) {
if (campaign.status === 'Scheduled' && campaign.scheduledAt) {
return 'In 2 hours';
}
if (!campaign.sentAt) {
return 'Not set';
}
return campaign.sentAt.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}
private getTimeLabel(campaign: Campaign) {
const value = campaign.status === 'Scheduled' ? campaign.scheduledAt : campaign.sentAt;
if (!value) {
return '';
}
return value.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
});
}
private average(values: number[]) {
if (values.length === 0) {
return 0;
}
return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1));
}
private getSortScore(campaign: Campaign) {
const statusWeight =
campaign.status === 'Sent'
? 4000000000000
: campaign.status === 'Scheduled'
? 3000000000000
: campaign.status === 'Draft'
? 2000000000000
: 1000000000000;
const dateWeight = (campaign.sentAt || campaign.scheduledAt || campaign.createdAt).getTime();
return statusWeight + dateWeight;
}
private async findActor(userId: string, email: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return {
id: actor?.id || userId,
name: actor?.name || email,
email: actor?.email || email,
};
}
private async generateCampaignCode() {
const latest = await this.prisma.campaign.findMany({
select: { code: true },
orderBy: { createdAt: 'desc' },
take: 20,
});
const numbers = latest
.map((campaign) => Number(campaign.code.replace(/[^0-9]/g, '')))
.filter((value) => Number.isFinite(value));
const next = (numbers.length ? Math.max(...numbers) : 98200) + 1;
return `CAM-${next}`;
}
private slugifyFileName(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
private async dispatchCampaign(campaignId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
include: {
recipients: true,
},
});
if (!campaign) {
throw new Error('Campaign not found');
}
let recipients = campaign.recipients;
if (recipients.length === 0) {
recipients = await this.generateRecipientsForCampaign(campaign);
}
const processedRecipients = recipients.map((recipient, index) => {
const mod = index % 11;
const status = mod === 0 ? 'Failed' : mod % 3 === 0 ? 'Read' : 'Delivered';
const sentAt = new Date(Date.now() + index * 60 * 1000);
return {
id: recipient.id,
status,
sentAt,
errorReason: status === 'Failed' ? 'Simulated Provider Rejection' : null,
deviceOs: status === 'Failed'
? 'Android'
: index % 4 === 0
? 'iOS'
: index % 5 === 0
? 'Web/Desktop'
: 'Android',
};
});
const deliveredCount = processedRecipients.filter((recipient) => recipient.status !== 'Failed').length;
const readCount = processedRecipients.filter((recipient) => recipient.status === 'Read').length;
const failedCount = processedRecipients.filter((recipient) => recipient.status === 'Failed').length;
const totalRecipients = processedRecipients.length;
const deliveryRate = totalRecipients > 0 ? Number(((deliveredCount / totalRecipients) * 100).toFixed(1)) : 0;
const readRate = totalRecipients > 0 ? Number(((readCount / totalRecipients) * 100).toFixed(1)) : 0;
await this.prisma.$transaction(async (tx) => {
for (const recipient of processedRecipients) {
await tx.campaignRecipient.update({
where: { id: recipient.id },
data: {
status: recipient.status,
sentAt: recipient.sentAt,
errorReason: recipient.errorReason,
deviceOs: recipient.deviceOs,
},
});
}
await tx.campaign.update({
where: { id: campaign.id },
data: {
status: 'Sent',
totalRecipients,
deliveredCount,
readCount,
failedCount,
deliveryRate,
readRate,
sentAt: new Date(),
scheduledAt: null,
},
});
await tx.auditLog.create({
data: {
actorUserId: null,
actorName: 'Campaign Worker',
actorEmail: null,
actionType: 'Campaign Delivered',
module: 'Campaigns',
severity: 'default',
details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients.`,
},
});
});
}
private async generateRecipientsForCampaign(campaign: Campaign) {
const contacts = await this.prisma.contact.findMany({
orderBy: { createdAt: 'asc' },
take: Math.min(Math.max(campaign.totalRecipients, 1), 250),
});
const desiredCount = Math.min(Math.max(campaign.totalRecipients, 1), 250);
const rows = Array.from({ length: desiredCount }, (_, index) => {
const contact = contacts[index];
const phoneNumber = contact?.phoneNumber || this.syntheticPhoneNumber(index);
return {
id: randomUUID(),
campaignId: campaign.id,
phoneNumber,
status: 'Queued',
sentAt: null,
errorReason: null,
deviceOs: null,
};
});
await this.prisma.campaignRecipient.createMany({
data: rows,
});
return this.prisma.campaignRecipient.findMany({
where: { campaignId: campaign.id },
orderBy: { createdAt: 'asc' },
});
}
private syntheticPhoneNumber(index: number) {
return `+1 (555) ${String(100 + Math.floor(index / 100)).padStart(3, '0')}-${String(1000 + (index % 1000)).padStart(4, '0')}`;
}
}

View File

@ -0,0 +1,99 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateCampaignDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
name!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
audienceLabel!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
audienceGroup!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
status?: string;
@Transform(({ value }) => Number(value))
@IsInt()
@Min(0)
@Max(1000000)
totalRecipients!: number;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
scheduledAt?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
templateName?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
language?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
messageTitle?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
messageBody?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
primaryButton?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
secondaryButton?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
bannerImageUrl?: string;
}

View File

@ -0,0 +1,22 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator';
export class SendCampaignDto {
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
mode?: 'now' | 'scheduled';
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
scheduledAt?: string;
}

View File

@ -0,0 +1,106 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateCampaignDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
name?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
audienceLabel?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
audienceGroup?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
status?: string;
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
return Number(value);
})
@IsOptional()
@IsInt()
@Min(0)
@Max(1000000)
totalRecipients?: number;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
scheduledAt?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
templateName?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
language?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
messageTitle?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
messageBody?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
primaryButton?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
secondaryButton?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
bannerImageUrl?: string;
}

View File

@ -0,0 +1,32 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('Missing authorization header');
}
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
throw new UnauthorizedException('Invalid authorization header');
}
try {
const user = await this.authService.verifyAccessToken(token);
(request as Request & { user: AuthenticatedUser }).user = user;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
return true;
}
}

View File

@ -0,0 +1,25 @@
export function normalizeEmail(value: string) {
return value.trim().toLowerCase();
}
export function normalizeOptionalEmail(value?: string) {
if (!value) {
return undefined;
}
const normalized = normalizeEmail(value);
return normalized || undefined;
}
export function normalizeText(value?: string) {
if (!value) {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
export function normalizePhoneNumber(value: string) {
return value.trim();
}

View File

@ -0,0 +1,13 @@
import * as bcrypt from 'bcryptjs';
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function comparePassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
export function hasMinimumPasswordLength(password: string) {
return password.trim().length >= 8;
}

View File

@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRED_PERMISSION_KEY = 'required_permission';
export type PermissionAction = 'view' | 'edit' | 'delete' | 'manage';
export function RequirePermission(module: string, action: PermissionAction) {
return SetMetadata(REQUIRED_PERMISSION_KEY, { module, action });
}

View File

@ -0,0 +1,124 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthenticatedUser } from '../auth/auth.types';
import { REQUIRED_PERMISSION_KEY, type PermissionAction } from './permission.decorator';
type PermissionRequirement = {
module: string;
action: PermissionAction;
};
type PermissionRow = {
id?: unknown;
values?: unknown;
};
const fallbackRolePermissions: Record<string, Record<string, Partial<Record<PermissionAction, boolean>>>> = {
admin: {
campaigns: { view: true, edit: true, delete: true, manage: true },
templates: { view: true, edit: true, delete: true, manage: true },
users: { view: true, edit: true, delete: true, manage: true },
roles: { view: true, edit: true, delete: true, manage: true },
contacts: { view: true, edit: true, delete: true, manage: true },
conversations: { view: true, edit: true, delete: true, manage: true },
analytics: { view: true, edit: true, delete: true, manage: true },
settings: { view: true, edit: true, delete: true, manage: true },
},
editor: {
campaigns: { view: true, edit: true, delete: false, manage: false },
templates: { view: true, edit: true, delete: false, manage: false },
contacts: { view: true, edit: false, delete: false, manage: false },
conversations: { view: true, edit: true, delete: false, manage: false },
analytics: { view: true, edit: false, delete: false, manage: false },
},
agent: {
campaigns: { view: true, edit: false, delete: false, manage: false },
templates: { view: true, edit: false, delete: false, manage: false },
contacts: { view: true, edit: true, delete: false, manage: false },
conversations: { view: true, edit: true, delete: false, manage: false },
analytics: { view: true, edit: false, delete: false, manage: false },
},
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<PermissionRequirement | undefined>(
REQUIRED_PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!requirement) {
return true;
}
const request = context.switchToHttp().getRequest<Request & { user?: AuthenticatedUser }>();
const authUser = request.user;
if (!authUser?.sub) {
throw new ForbiddenException('Missing authenticated user context');
}
const user = await this.prisma.user.findUnique({
where: { id: authUser.sub },
select: {
role: {
select: {
key: true,
permissionsJson: true,
},
},
},
});
if (!user?.role) {
throw new ForbiddenException('No role assigned to this account');
}
if (user.role.key === 'admin') {
return true;
}
const allowed =
this.hasPermissionFromRoleJson(user.role.permissionsJson, requirement.module, requirement.action) ||
this.hasFallbackPermission(user.role.key, requirement.module, requirement.action);
if (!allowed) {
throw new ForbiddenException(`Missing permission: ${requirement.module}.${requirement.action}`);
}
return true;
}
private hasPermissionFromRoleJson(
permissionsJson: unknown,
module: string,
action: PermissionAction,
) {
if (!Array.isArray(permissionsJson)) {
return false;
}
const row = permissionsJson.find((item): item is PermissionRow => {
if (!item || typeof item !== 'object') return false;
return typeof (item as PermissionRow).id === 'string' && (item as PermissionRow).id === module;
});
if (!row || !row.values || typeof row.values !== 'object') {
return false;
}
const value = (row.values as Record<string, unknown>)[action];
return value === true;
}
private hasFallbackPermission(roleKey: string, module: string, action: PermissionAction) {
return fallbackRolePermissions[roleKey]?.[module]?.[action] === true;
}
}

View File

@ -0,0 +1,36 @@
import {
ArgumentsHost,
Catch,
ConflictException,
ExceptionFilter,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
if (exception.code === 'P2002') {
const target = Array.isArray(exception.meta?.target)
? exception.meta.target.join(', ')
: 'unique field';
const error = new ConflictException(`Resource already exists for ${target}`);
return response.status(error.getStatus()).json(error.getResponse());
}
if (exception.code === 'P2025') {
const error = new NotFoundException('Resource not found');
return response.status(error.getStatus()).json(error.getResponse());
}
return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Database request failed',
error: 'Internal Server Error',
});
}
}

270
backend/src/config/env.ts Normal file
View File

@ -0,0 +1,270 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { config as loadDotEnv } from 'dotenv';
type AppConfig = {
databaseUrl: string;
jwtSecret: string;
jwtExpiresIn: string;
jwtRefreshSecret: string;
jwtRefreshExpiresIn: string;
port: number;
frontendOrigin: string;
corsOrigins: string[];
publicApiUrl: string;
redisUrl: string;
webhookVerifyToken: string;
webhookSharedSecret: string;
metaWebhookAppSecret?: string;
webhookAllowUnsigned: boolean;
nodeEnv: string;
isProduction: boolean;
mailHost?: string;
mailPort: number;
mailSecure: boolean;
mailUser?: string;
mailPassword?: string;
mailFrom: string;
authLoginMaxAttempts: number;
authLoginWindowMinutes: number;
authTwoFactorMaxAttempts: number;
authTwoFactorWindowMinutes: number;
authPasswordResetMaxAttempts: number;
authPasswordResetWindowMinutes: number;
};
let cachedConfig: AppConfig | null = null;
const INSECURE_SECRET_PATTERNS = new Set([
'change-me',
'change-me-webhook-token',
'change-me-webhook-secret',
'supersecretjwt',
'password',
'secret',
]);
function loadEnvironmentFiles() {
const candidatePaths = [
resolve(process.cwd(), '../.env'),
resolve(process.cwd(), '.env'),
resolve(process.cwd(), '../.env.local'),
resolve(process.cwd(), '.env.local'),
];
for (const path of candidatePaths) {
if (existsSync(path)) {
loadDotEnv({ path, override: false, quiet: true });
}
}
}
function requireEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function parsePort(value: string | undefined) {
const parsed = Number(value ?? '3001');
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid PORT value: ${value}`);
}
return parsed;
}
function parseNodeEnv(value: string | undefined) {
const normalized = value?.trim().toLowerCase() || 'development';
if (normalized === 'development' || normalized === 'production' || normalized === 'test') {
return normalized;
}
throw new Error(`Invalid NODE_ENV value: ${value}`);
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (value === undefined) {
return fallback;
}
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function parseOptionalPort(value: string | undefined, fallback: number) {
if (!value?.trim()) {
return fallback;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid port value: ${value}`);
}
return parsed;
}
function parsePositiveInt(name: string, value: string | undefined, fallback: number) {
if (!value?.trim()) {
return fallback;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return parsed;
}
function parseUrl(name: string, value: string) {
try {
return new URL(value);
} catch {
throw new Error(`Invalid ${name} URL: ${value}`);
}
}
function parseOrigins(value: string | undefined) {
const rawOrigins = value?.trim() || 'http://localhost:3000';
const origins = rawOrigins
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (origins.length === 0) {
throw new Error('FRONTEND_ORIGIN must contain at least one origin');
}
origins.forEach((origin) => parseUrl('FRONTEND_ORIGIN', origin));
return origins;
}
function assertStrongSecret(name: string, value: string, isProduction: boolean) {
const minimumLength = isProduction ? 32 : 12;
if (value.length < minimumLength) {
throw new Error(`${name} must be at least ${minimumLength} characters long`);
}
if (isProduction && INSECURE_SECRET_PATTERNS.has(value.toLowerCase())) {
throw new Error(`${name} uses an insecure placeholder value`);
}
}
function assertProductionUrl(name: string, value: string, isProduction: boolean) {
const parsed = parseUrl(name, value);
if (!isProduction) {
return;
}
if (parsed.protocol !== 'https:') {
throw new Error(`${name} must use https in production`);
}
}
function assertProductionMailConfig(config: Pick<AppConfig, 'mailHost' | 'mailUser' | 'mailPassword' | 'mailFrom'>, isProduction: boolean) {
if (!isProduction) {
return;
}
const hasAnyMailConfig = Boolean(config.mailHost || config.mailUser || config.mailPassword);
if (!hasAnyMailConfig) {
return;
}
if (!config.mailHost || !config.mailUser || !config.mailPassword) {
throw new Error('MAIL_HOST, MAIL_USER, and MAIL_PASSWORD must all be set together in production');
}
if (config.mailFrom.toLowerCase() === 'no-reply@bizone.id') {
throw new Error('MAIL_FROM must be set to a real sender address in production');
}
}
loadEnvironmentFiles();
export function getAppConfig(): AppConfig {
if (cachedConfig) {
return cachedConfig;
}
const nodeEnv = parseNodeEnv(process.env.NODE_ENV);
const isProduction = nodeEnv === 'production';
const corsOrigins = parseOrigins(process.env.FRONTEND_ORIGIN);
const frontendOrigin = corsOrigins[0];
const port = parsePort(process.env.PORT);
const publicApiUrl = process.env.PUBLIC_API_URL?.trim() || `http://localhost:${port}`;
const jwtSecret = requireEnv('JWT_SECRET');
const jwtRefreshSecret = requireEnv('JWT_REFRESH_SECRET');
const webhookVerifyToken = process.env.WEBHOOK_VERIFY_TOKEN?.trim() || 'change-me-webhook-token';
const webhookSharedSecret = process.env.WEBHOOK_SHARED_SECRET?.trim() || 'change-me-webhook-secret';
const webhookAllowUnsigned = parseBoolean(
process.env.WEBHOOK_ALLOW_UNSIGNED,
nodeEnv !== 'production',
);
cachedConfig = {
databaseUrl: requireEnv('DATABASE_URL'),
jwtSecret,
jwtExpiresIn: process.env.JWT_EXPIRES_IN?.trim() || '1d',
jwtRefreshSecret,
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN?.trim() || '30d',
port,
frontendOrigin,
corsOrigins,
publicApiUrl,
redisUrl: process.env.REDIS_URL?.trim() || 'redis://127.0.0.1:6379',
webhookVerifyToken,
webhookSharedSecret,
metaWebhookAppSecret: process.env.META_WEBHOOK_APP_SECRET?.trim() || undefined,
webhookAllowUnsigned,
nodeEnv,
isProduction,
mailHost: process.env.MAIL_HOST?.trim() || undefined,
mailPort: parseOptionalPort(process.env.MAIL_PORT, 465),
mailSecure: parseBoolean(process.env.MAIL_SECURE, true),
mailUser: process.env.MAIL_USER?.trim() || undefined,
mailPassword: process.env.MAIL_PASSWORD?.trim() || undefined,
mailFrom: process.env.MAIL_FROM?.trim() || process.env.MAIL_USER?.trim() || 'no-reply@bizone.id',
authLoginMaxAttempts: parsePositiveInt('AUTH_LOGIN_MAX_ATTEMPTS', process.env.AUTH_LOGIN_MAX_ATTEMPTS, 5),
authLoginWindowMinutes: parsePositiveInt('AUTH_LOGIN_WINDOW_MINUTES', process.env.AUTH_LOGIN_WINDOW_MINUTES, 15),
authTwoFactorMaxAttempts: parsePositiveInt(
'AUTH_2FA_MAX_ATTEMPTS',
process.env.AUTH_2FA_MAX_ATTEMPTS,
5,
),
authTwoFactorWindowMinutes: parsePositiveInt(
'AUTH_2FA_WINDOW_MINUTES',
process.env.AUTH_2FA_WINDOW_MINUTES,
10,
),
authPasswordResetMaxAttempts: parsePositiveInt(
'AUTH_PASSWORD_RESET_MAX_ATTEMPTS',
process.env.AUTH_PASSWORD_RESET_MAX_ATTEMPTS,
3,
),
authPasswordResetWindowMinutes: parsePositiveInt(
'AUTH_PASSWORD_RESET_WINDOW_MINUTES',
process.env.AUTH_PASSWORD_RESET_WINDOW_MINUTES,
30,
),
};
assertStrongSecret('JWT_SECRET', cachedConfig.jwtSecret, isProduction);
assertStrongSecret('JWT_REFRESH_SECRET', cachedConfig.jwtRefreshSecret, isProduction);
assertStrongSecret('WEBHOOK_VERIFY_TOKEN', cachedConfig.webhookVerifyToken, isProduction);
assertStrongSecret('WEBHOOK_SHARED_SECRET', cachedConfig.webhookSharedSecret, isProduction);
assertProductionUrl('PUBLIC_API_URL', cachedConfig.publicApiUrl, isProduction);
cachedConfig.corsOrigins.forEach((origin) => assertProductionUrl('FRONTEND_ORIGIN', origin, isProduction));
if (isProduction && cachedConfig.webhookAllowUnsigned) {
throw new Error('WEBHOOK_ALLOW_UNSIGNED cannot be enabled in production');
}
assertProductionMailConfig(cachedConfig, isProduction);
return cachedConfig;
}

View File

@ -0,0 +1,86 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { Response } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { ContactsService } from './contacts.service';
import { CreateContactDto } from '../dto/create-contact.dto';
import { UpdateContactDto } from '../dto/update-contact.dto';
import { AuthGuard } from '../common/auth.guard';
@UseGuards(AuthGuard)
@Controller('contacts')
export class ContactsController {
constructor(private readonly contactsService: ContactsService) {}
@Get()
findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('tag') tag?: string,
) {
return this.contactsService.findAll({
page: Number(page || '1'),
limit: Number(limit || '10'),
search,
status,
tag,
});
}
@Get('export')
async exportContacts(
@Req() request: Request & { user: AuthenticatedUser },
@Res() response: Response,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('tag') tag?: string,
) {
const result = await this.contactsService.exportContacts(
{
page: 1,
limit: 100000,
search,
status,
tag,
},
request.user,
request.ip,
);
response.setHeader('Content-Type', result.contentType);
response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`);
response.send(result.buffer);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.contactsService.findOne(id);
}
@Post()
create(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateContactDto,
) {
return this.contactsService.create(dto, request.user, request.ip);
}
@Patch(':id')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateContactDto,
) {
return this.contactsService.update(id, dto, request.user, request.ip);
}
@Delete(':id')
remove(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
) {
return this.contactsService.remove(id, request.user, request.ip);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';
@Module({
imports: [AuthModule],
controllers: [ContactsController],
providers: [ContactsService],
})
export class ContactsModule {}

View File

@ -0,0 +1,413 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AuthenticatedUser } from '../auth/auth.types';
import {
normalizeOptionalEmail,
normalizePhoneNumber,
normalizeText,
} from '../common/normalize';
import { PrismaService } from '../prisma/prisma.service';
import { CreateContactDto } from '../dto/create-contact.dto';
import { UpdateContactDto } from '../dto/update-contact.dto';
type ContactsQuery = {
page: number;
limit: number;
search?: string;
status?: string;
tag?: string;
};
@Injectable()
export class ContactsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: ContactsQuery) {
const contacts = await this.prisma.contact.findMany({ orderBy: { createdAt: 'desc' } });
const enriched = await Promise.all(contacts.map((contact) => this.enrichContactRow(contact)));
const filtered = enriched.filter((contact) => {
const matchesSearch = !query.search
|| [contact.name, contact.email, contact.phoneNumber, contact.company, contact.location]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query.search!.trim().toLowerCase()));
const matchesStatus = !query.status
|| query.status === 'all'
|| contact.status.toLowerCase() === query.status.trim().toLowerCase();
const matchesTag = !query.tag
|| query.tag === 'all'
|| contact.tags.some((tag) => tag.toLowerCase() === query.tag!.trim().toLowerCase());
return matchesSearch && matchesStatus && matchesTag;
});
const pageSize = Math.min(Math.max(query.limit || 10, 1), 100);
const page = Math.max(query.page || 1, 1);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
const availableTags = Array.from(new Set(enriched.flatMap((contact) => contact.tags))).sort();
return {
items,
total,
page,
pageSize,
totalPages,
availableTags,
statusCounts: {
active: enriched.filter((item) => item.status === 'Active').length,
inactive: enriched.filter((item) => item.status === 'Inactive').length,
},
};
}
async findOne(id: string) {
const contact = await this.prisma.contact.findUnique({ where: { id } });
if (!contact) {
throw new NotFoundException('Contact not found');
}
const [row, webhookEvents, auditLogs] = await Promise.all([
this.enrichContactRow(contact),
this.prisma.webhookEvent.findMany({
where: {
OR: [
{ senderPhone: contact.phoneNumber },
{ recipientPhone: contact.phoneNumber },
],
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.prisma.auditLog.findMany({
where: {
module: 'Contacts',
OR: [
{ details: { contains: contact.phoneNumber, mode: 'insensitive' } },
{ details: { contains: contact.name, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
]);
const notes = this.buildNotes(contact);
const history = [
...webhookEvents.map((event) => ({
id: event.id,
type: event.senderPhone === contact.phoneNumber ? 'inbound' : 'message',
title: event.senderPhone === contact.phoneNumber ? 'Inbound Message' : 'Message Sent',
at: event.createdAt.toISOString(),
summary:
event.senderPhone === contact.phoneNumber
? `Inbound WhatsApp event ${event.eventType} received from this contact.`
: `Outbound WhatsApp event ${event.eventType} delivered to this contact.`,
status: event.processingStatus,
errorReason: event.processingNotes,
})),
...auditLogs.map((log) => ({
id: log.id,
type: log.actionType.toLowerCase().includes('deleted')
? 'status'
: log.actionType.toLowerCase().includes('created')
? 'tag'
: 'system',
title: log.actionType,
at: log.createdAt.toISOString(),
summary: log.details,
status: log.severity,
errorReason: null as string | null,
})),
]
.sort((left, right) => new Date(right.at).getTime() - new Date(left.at).getTime())
.slice(0, 20);
return {
contact: {
...row,
notes,
history,
},
};
}
async exportContacts(query: ContactsQuery, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const result = await this.findAll(query);
const lines = [
['Name', 'Email', 'Phone Number', 'Company', 'Location', 'Status', 'Tags', 'Last Message'].join(','),
...result.items.map((contact) =>
[
contact.name,
contact.email || '',
contact.phoneNumber,
contact.company || '',
contact.location,
contact.status,
contact.tags.join(' | '),
contact.lastMessageLabel,
]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
.join(','),
),
].join('\n');
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Contact Exported',
module: 'Contacts',
ipAddress: ipAddress || null,
severity: 'default',
details: `Exported ${result.total} contact rows.`,
},
});
return {
fileName: 'contacts-directory.csv',
contentType: 'text/csv; charset=utf-8',
buffer: Buffer.from(lines, 'utf8'),
};
}
async create(dto: CreateContactDto, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const contact = await this.prisma.contact.create({
data: {
name: normalizeText(dto.name)!,
phoneNumber: normalizePhoneNumber(dto.phoneNumber),
email: normalizeOptionalEmail(dto.email),
company: normalizeText(dto.company),
notes: normalizeText(dto.notes),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Contact Created',
module: 'Contacts',
ipAddress: ipAddress || null,
severity: 'default',
details: `Created contact ${contact.name} (${contact.phoneNumber}).`,
},
});
return contact;
}
async update(id: string, dto: UpdateContactDto, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const contact = await this.prisma.contact.update({
where: { id },
data: {
name: dto.name === undefined ? undefined : normalizeText(dto.name),
phoneNumber:
dto.phoneNumber === undefined ? undefined : normalizePhoneNumber(dto.phoneNumber),
email: dto.email === undefined ? undefined : normalizeOptionalEmail(dto.email),
company: dto.company === undefined ? undefined : normalizeText(dto.company),
notes: dto.notes === undefined ? undefined : normalizeText(dto.notes),
isBlacklisted: dto.isBlacklisted,
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Contact Updated',
module: 'Contacts',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated contact ${contact.name} (${contact.phoneNumber}).`,
},
});
return contact;
}
async remove(id: string, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const contact = await this.prisma.contact.delete({ where: { id } });
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Contact Deleted',
module: 'Contacts',
ipAddress: ipAddress || null,
severity: 'default',
details: `Deleted contact ${contact.name} (${contact.phoneNumber}).`,
},
});
return contact;
}
private async enrichContactRow(contact: Awaited<ReturnType<typeof this.prisma.contact.findFirstOrThrow>>) {
const latestWebhook = await this.prisma.webhookEvent.findFirst({
where: {
OR: [
{ senderPhone: contact.phoneNumber },
{ recipientPhone: contact.phoneNumber },
],
},
orderBy: { createdAt: 'desc' },
});
const tags = this.deriveTags(contact);
return {
id: contact.id,
name: contact.name,
email: contact.email,
phoneNumber: contact.phoneNumber,
company: contact.company,
status: this.deriveStatus(contact),
tags,
location: this.deriveLocation(contact.phoneNumber, contact.email),
lastMessageAt: latestWebhook?.createdAt.toISOString() || null,
lastMessageLabel: latestWebhook
? latestWebhook.createdAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
: contact.updatedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}),
lastSeenLabel: this.relativeTime(latestWebhook?.createdAt || contact.updatedAt),
isBlacklisted: contact.isBlacklisted,
avatarInitials: this.getInitials(contact.name),
createdAt: contact.createdAt.toISOString(),
updatedAt: contact.updatedAt.toISOString(),
};
}
private deriveStatus(contact: {
isBlacklisted: boolean;
updatedAt: Date;
}) {
if (contact.isBlacklisted) {
return 'Inactive';
}
const inactiveThreshold = Date.now() - 1000 * 60 * 60 * 24 * 45;
return contact.updatedAt.getTime() >= inactiveThreshold ? 'Active' : 'Inactive';
}
private deriveTags(contact: {
company: string | null;
notes: string | null;
createdAt: Date;
email: string | null;
}) {
const haystack = `${contact.company || ''} ${contact.notes || ''} ${contact.email || ''}`.toLowerCase();
const tags = new Set<string>();
if (haystack.includes('vip') || haystack.includes('premium')) tags.add('VIP');
if (haystack.includes('retail') || haystack.includes('shop') || haystack.includes('store')) tags.add('Retail');
if (haystack.includes('support') || haystack.includes('help') || haystack.includes('issue')) tags.add('Support');
if (haystack.includes('lead') || haystack.includes('prospect')) tags.add('Lead');
if (haystack.includes('wholesale') || haystack.includes('logistics') || haystack.includes('distribution')) tags.add('Wholesale');
if (contact.createdAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 * 30) tags.add('New');
if (contact.company?.toLowerCase().includes('bizone')) tags.add('Partner');
if (tags.size === 0) {
tags.add('General');
}
return Array.from(tags);
}
private deriveLocation(phoneNumber: string, email?: string | null) {
const value = phoneNumber.replace(/\s+/g, '');
if (value.startsWith('+62') || value.startsWith('62')) return 'Jakarta, ID';
if (value.startsWith('+65') || value.startsWith('65')) return 'Singapore, SG';
if (value.startsWith('+1') || value.startsWith('1')) return 'San Francisco, US';
if (email?.endsWith('.es')) return 'Madrid, ES';
if (email?.endsWith('.net')) return 'London, UK';
return 'Regional Contact';
}
private buildNotes(contact: { notes: string | null; company: string | null; createdAt: Date }) {
const items = (contact.notes || '')
.split(/\n+/)
.map((note) => note.trim())
.filter(Boolean);
if (items.length === 0) {
return [
{
id: 'default-1',
author: 'System Admin',
dateLabel: contact.createdAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}),
body: contact.company
? `Associated with ${contact.company}. Keep communication contextual to this account.`
: 'No private notes yet. Add a note to capture relationship context and preferences.',
emphasized: true,
},
];
}
return items.map((item, index) => ({
id: `note-${index + 1}`,
author: index === 0 ? 'System Admin' : 'Ops Team',
dateLabel: contact.createdAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}),
body: item,
emphasized: index === 0,
}));
}
private relativeTime(date: Date) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(1, Math.round(diffMs / 60000));
if (diffMinutes < 60) return `${diffMinutes} mins ago`;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} hours ago`;
const diffDays = Math.round(diffHours / 24);
return `${diffDays} days ago`;
}
private getInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
}
}

View File

@ -0,0 +1,39 @@
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { SendConversationMessageDto } from './dto/send-message.dto';
import { ConversationsService } from './conversations.service';
@UseGuards(AuthGuard)
@Controller('conversations')
export class ConversationsController {
constructor(private readonly conversationsService: ConversationsService) {}
@Get()
findAll(@Query('filter') filter?: string, @Query('search') search?: string) {
return this.conversationsService.findAll({ filter, search });
}
@Get(':contactId')
findOne(@Param('contactId') contactId: string) {
return this.conversationsService.findOne(contactId);
}
@Post(':contactId/messages')
sendMessage(
@Req() request: Request & { user: AuthenticatedUser },
@Param('contactId') contactId: string,
@Body() dto: SendConversationMessageDto,
) {
return this.conversationsService.sendMessage(contactId, dto, request.user, request.ip);
}
@Post(':contactId/assign')
assignToCurrentUser(
@Req() request: Request & { user: AuthenticatedUser },
@Param('contactId') contactId: string,
) {
return this.conversationsService.assignToCurrentUser(contactId, request.user, request.ip);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ConversationsController } from './conversations.controller';
import { ConversationsService } from './conversations.service';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [ConversationsController],
providers: [ConversationsService],
exports: [ConversationsService],
})
export class ConversationsModule {}

View File

@ -0,0 +1,487 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Contact } from '@prisma/client';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeText } from '../common/normalize';
import { PrismaService } from '../prisma/prisma.service';
type ConversationFilter = 'all' | 'active' | 'pending';
@Injectable()
export class ConversationsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: { filter?: string; search?: string }) {
const contacts = await this.prisma.contact.findMany({
orderBy: { createdAt: 'desc' },
});
const items = await Promise.all(contacts.map((contact) => this.buildConversationSummary(contact)));
const normalizedFilter = this.normalizeFilter(query.filter);
const searchNeedle = query.search?.trim().toLowerCase();
return items
.filter((item) => {
const matchesFilter =
normalizedFilter === 'all'
? true
: normalizedFilter === 'pending'
? item.status === 'PENDING'
: item.status === 'ACTIVE' || item.status === 'NEW';
const matchesSearch =
!searchNeedle ||
[item.name, item.email, item.phone, item.topic, item.snippet, item.location]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchNeedle));
return matchesFilter && matchesSearch;
})
.sort((left, right) => new Date(right.lastActivityAt).getTime() - new Date(left.lastActivityAt).getTime());
}
async findOne(contactId: string) {
const contact = await this.prisma.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new NotFoundException('Conversation not found');
}
await this.prisma.conversationMessage.updateMany({
where: {
contactId,
direction: 'incoming',
readAt: null,
},
data: {
readAt: new Date(),
},
});
return this.buildConversationDetail(contact);
}
async sendMessage(contactId: string, dto: { body: string }, user: AuthenticatedUser, ipAddress?: string) {
const contact = await this.prisma.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new NotFoundException('Conversation not found');
}
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const body = normalizeText(dto.body);
if (!body) {
throw new BadRequestException('Message body is required');
}
const providerSend = await this.sendViaConfiguredProvider(contact.phoneNumber, body);
const message = await this.prisma.conversationMessage.create({
data: {
contactId,
direction: 'outgoing',
source: providerSend.provider,
status: providerSend.status,
body,
senderUserId: actor?.id || user.sub,
senderName: actor?.name || user.email,
externalMessageId: providerSend.externalMessageId,
},
});
await this.prisma.contact.update({
where: { id: contactId },
data: {
assignedUserId: actor?.id || user.sub,
assignedUserName: actor?.name || user.email,
assignedAt: new Date(),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Conversation Reply Sent',
module: 'Conversations',
ipAddress: ipAddress || null,
severity: 'default',
details: `Sent reply to ${contact.name} (${contact.phoneNumber}) via ${providerSend.provider}.`,
},
});
return {
success: true,
message: this.mapThreadMessage(message),
};
}
async assignToCurrentUser(contactId: string, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const contact = await this.prisma.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new NotFoundException('Conversation not found');
}
await this.prisma.contact.update({
where: { id: contactId },
data: {
assignedUserId: actor?.id || user.sub,
assignedUserName: actor?.name || user.email,
assignedAt: new Date(),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Conversation Assigned',
module: 'Conversations',
ipAddress: ipAddress || null,
severity: 'default',
details: `Assigned conversation ${contact.name} (${contact.phoneNumber}) to ${actor?.name || user.email}.`,
},
});
return { success: true };
}
async syncInboundFromWebhookEvent(input: {
webhookEventId: string;
contactId: string;
externalMessageId?: string | null;
body: string;
occurredAt: Date;
}) {
const normalizedBody = normalizeText(input.body) || 'Inbound message received.';
await this.prisma.conversationMessage.upsert({
where: { webhookEventId: input.webhookEventId },
update: {
body: normalizedBody,
externalMessageId: input.externalMessageId || undefined,
occurredAt: input.occurredAt,
status: 'received',
},
create: {
contactId: input.contactId,
direction: 'incoming',
messageType: 'text',
source: 'webhook',
status: 'received',
body: normalizedBody,
externalMessageId: input.externalMessageId || undefined,
webhookEventId: input.webhookEventId,
occurredAt: input.occurredAt,
},
});
}
private async buildConversationSummary(contact: Contact) {
const [messages, webhooks] = await Promise.all([
this.prisma.conversationMessage.findMany({
where: { contactId: contact.id },
orderBy: { occurredAt: 'desc' },
take: 20,
}),
this.prisma.webhookEvent.findMany({
where: {
OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }],
},
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const latestMessage = messages[0] || null;
const latestWebhook = webhooks[0] || null;
const latestMessageAt = latestMessage?.occurredAt?.getTime() || 0;
const latestWebhookAt = latestWebhook?.createdAt?.getTime() || 0;
const lastActivityAt = new Date(Math.max(latestMessageAt, latestWebhookAt, contact.updatedAt.getTime()));
const latestSnippet =
latestMessageAt >= latestWebhookAt
? latestMessage?.body
: this.extractWebhookSnippet(latestWebhook?.payloadJson) || latestWebhook?.eventType || 'No activity yet';
const tags = this.deriveTags(contact);
const unreadCount = messages.filter((message) => message.direction === 'incoming' && !message.readAt).length;
const fallbackInboundUnread =
unreadCount === 0 &&
latestWebhook?.eventType === 'message.inbound' &&
latestWebhookAt > latestMessageAt;
const status = fallbackInboundUnread || unreadCount > 0
? 'NEW'
: lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24
? 'ACTIVE'
: 'PENDING';
return {
id: contact.id,
name: contact.name,
initials: this.getInitials(contact.name),
time: this.relativeTime(lastActivityAt),
status,
tone: status === 'PENDING' ? 'warning' : status === 'ACTIVE' ? 'success' : 'info',
topic: tags[0]?.toUpperCase() || 'GENERAL',
snippet: latestSnippet || 'No activity yet',
online: lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 10,
location: this.deriveLocation(contact.phoneNumber, contact.email),
email: contact.email || 'N/A',
phone: contact.phoneNumber,
customerSince: `Customer since ${contact.createdAt.toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})}`,
tags,
lastActivityAt: lastActivityAt.toISOString(),
unreadCount,
assignedAgentName: contact.assignedUserName || null,
};
}
private async buildConversationDetail(contact: Contact) {
const [messages, webhookEvents, auditLogs] = await Promise.all([
this.prisma.conversationMessage.findMany({
where: { contactId: contact.id },
orderBy: { occurredAt: 'asc' },
take: 200,
}),
this.prisma.webhookEvent.findMany({
where: {
OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }],
},
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.auditLog.findMany({
where: {
OR: [
{ details: { contains: contact.phoneNumber, mode: 'insensitive' } },
{ details: { contains: contact.name, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: 6,
}),
]);
const mirroredWebhookIds = new Set(messages.map((message) => message.webhookEventId).filter(Boolean));
const fallbackInboundMessages = webhookEvents
.filter((event) => event.eventType === 'message.inbound' && !mirroredWebhookIds.has(event.eventId))
.map((event) => ({
id: `webhook-${event.eventId}`,
direction: 'incoming' as const,
body: this.extractWebhookSnippet(event.payloadJson) || 'Inbound message received.',
time: this.formatThreadTime(event.createdAt),
occurredAt: event.createdAt.toISOString(),
}));
const threadMessages = [
...messages.map((message) => this.mapThreadMessage(message)),
...fallbackInboundMessages,
].sort((left, right) => new Date(left.occurredAt).getTime() - new Date(right.occurredAt).getTime());
const summary = await this.buildConversationSummary(contact);
return {
...summary,
messages: threadMessages.map(({ occurredAt: _occurredAt, ...message }) => message),
activity: this.buildRecentActivity(webhookEvents, auditLogs),
assignedAgentName: contact.assignedUserName || summary.assignedAgentName,
};
}
private mapThreadMessage(message: {
id: string;
direction: string;
body: string;
occurredAt: Date;
status?: string;
}) {
return {
id: message.id,
direction: message.direction === 'outgoing' ? 'outgoing' : 'incoming',
body: message.body,
time: this.formatThreadTime(message.occurredAt),
status: message.status || 'sent',
occurredAt: message.occurredAt.toISOString(),
};
}
private buildRecentActivity(
webhookEvents: Array<{ id: string; eventType: string; createdAt: Date; processingStatus: string }>,
auditLogs: Array<{ id: string; actionType: string; createdAt: Date; details: string; severity: string }>,
) {
return [
...webhookEvents.map((event) => ({
id: `webhook-${event.id}`,
title: event.eventType,
meta: `${event.processingStatus}${this.relativeTime(event.createdAt)}`,
tone: event.eventType === 'message.inbound' ? 'primary' : 'muted',
createdAt: event.createdAt.toISOString(),
})),
...auditLogs.map((log) => ({
id: `audit-${log.id}`,
title: log.actionType,
meta: `${log.severity}${this.relativeTime(log.createdAt)}`,
tone: log.severity === 'alert' ? 'primary' : 'muted',
createdAt: log.createdAt.toISOString(),
})),
]
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())
.slice(0, 5)
.map(({ createdAt: _createdAt, ...item }) => item);
}
private normalizeFilter(value?: string): ConversationFilter {
const normalized = value?.trim().toLowerCase();
if (normalized === 'active' || normalized === 'pending') {
return normalized;
}
return 'all';
}
private extractWebhookSnippet(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return '';
}
const record = payload as Record<string, unknown>;
const textRecord =
record.text && typeof record.text === 'object' && !Array.isArray(record.text)
? (record.text as Record<string, unknown>)
: null;
const interactiveRecord =
record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive)
? (record.interactive as Record<string, unknown>)
: null;
return [
typeof textRecord?.body === 'string' ? textRecord.body : null,
typeof record.body === 'string' ? record.body : null,
typeof record.caption === 'string' ? record.caption : null,
typeof interactiveRecord?.title === 'string' ? interactiveRecord.title : null,
typeof record.type === 'string' ? `[${record.type}]` : null,
].find((value) => typeof value === 'string' && value.trim()) || '';
}
private deriveTags(contact: { company: string | null; notes: string | null; createdAt: Date; email: string | null }) {
const haystack = `${contact.company || ''} ${contact.notes || ''} ${contact.email || ''}`.toLowerCase();
const tags = new Set<string>();
if (haystack.includes('vip') || haystack.includes('premium')) tags.add('Premium');
if (haystack.includes('support') || haystack.includes('help') || haystack.includes('issue')) tags.add('Support');
if (haystack.includes('lead') || haystack.includes('prospect')) tags.add('Lead');
if (haystack.includes('retail') || haystack.includes('shop') || haystack.includes('store')) tags.add('Inquiry');
if (contact.createdAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 * 30) tags.add('New');
if (tags.size === 0) tags.add('General');
return Array.from(tags);
}
private deriveLocation(phoneNumber: string, email?: string | null) {
const value = phoneNumber.replace(/\s+/g, '');
if (value.startsWith('+62') || value.startsWith('62')) return 'Jakarta, ID';
if (value.startsWith('+65') || value.startsWith('65')) return 'Singapore, SG';
if (value.startsWith('+1') || value.startsWith('1')) return 'San Francisco, CA';
if (email?.endsWith('.es')) return 'Madrid, ES';
if (email?.endsWith('.net')) return 'London, UK';
return 'Regional Contact';
}
private relativeTime(date: Date) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60)));
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
private formatThreadTime(date: Date) {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(date);
}
private getInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
}
private async sendViaConfiguredProvider(phoneNumber: string, body: string) {
const stored = await this.prisma.integrationConfig.findUnique({
where: { configKey: 'whatsapp' },
});
const configJson = (stored?.configJson as Record<string, unknown> | null) ?? {};
const provider = String(configJson.provider || stored?.provider || 'internal').toLowerCase();
const accessToken = typeof configJson.accessToken === 'string' ? configJson.accessToken : '';
const phoneNumberId = typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : '';
if (stored?.isEnabled && provider === 'meta' && accessToken && phoneNumberId) {
const response = await fetch(`https://graph.facebook.com/v20.0/${phoneNumberId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: phoneNumber.replace(/\D+/g, ''),
type: 'text',
text: {
preview_url: false,
body,
},
}),
});
const payload = (await response.json().catch(() => ({}))) as {
messages?: Array<{ id?: string }>;
};
if (!response.ok) {
return {
provider: 'meta',
status: 'failed',
externalMessageId: null,
};
}
return {
provider: 'meta',
status: 'queued',
externalMessageId: payload.messages?.[0]?.id || null,
};
}
return {
provider: 'internal',
status: 'sent',
externalMessageId: null,
};
}
}

View File

@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class SendConversationMessageDto {
@IsString()
@MinLength(1)
@MaxLength(4000)
body!: string;
}

View File

@ -0,0 +1,19 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { AuthGuard } from '../common/auth.guard';
@UseGuards(AuthGuard)
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('summary')
summary() {
return this.dashboardService.summary();
}
@Get('analytics-summary')
analyticsSummary() {
return this.dashboardService.analyticsSummary();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
imports: [AuthModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async summary() {
const totalContacts = await this.prisma.contact.count();
const totalWebhookEvents = await this.prisma.webhookEvent.count();
const failedWebhookEvents = await this.prisma.webhookEvent.count({
where: { processingStatus: 'failed' },
});
return {
totalContacts,
totalWebhookEvents,
deliveredRate: 0,
webhookHealth: failedWebhookEvents > 0 ? 'degraded' : 'healthy',
};
}
async analyticsSummary() {
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const lastHour = new Date(now.getTime() - 60 * 60 * 1000);
const [
totalJobs,
pendingJobs,
processingJobs,
failedJobs24h,
totalWebhookEvents,
verifiedWebhookEvents,
pendingWebhookEvents,
auditTrailTotal,
recentJobs,
recentWebhookEvents,
] = await this.prisma.$transaction([
this.prisma.job.count(),
this.prisma.job.count({ where: { status: 'queued' } }),
this.prisma.job.count({ where: { status: 'processing' } }),
this.prisma.job.count({
where: {
status: 'failed',
updatedAt: { gte: last24Hours },
},
}),
this.prisma.webhookEvent.count(),
this.prisma.webhookEvent.count({ where: { verified: true } }),
this.prisma.webhookEvent.count({
where: {
processingStatus: { not: 'processed' },
},
}),
this.prisma.auditLog.count(),
this.prisma.job.findMany({
where: {
createdAt: { gte: lastHour },
},
select: {
queueName: true,
status: true,
attempts: true,
maxAttempts: true,
createdAt: true,
processedAt: true,
},
}),
this.prisma.webhookEvent.findMany({
where: {
createdAt: { gte: lastHour },
},
select: {
createdAt: true,
verified: true,
processingStatus: true,
},
}),
]);
const workerGroups = recentJobs.reduce<Record<string, number>>((acc, job) => {
const key = `${job.queueName}-worker`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const workerHealth = Object.entries(workerGroups).map(([name, count]) => {
const load = Math.max(4, Math.min(100, count * 12));
return {
name,
load,
tone: load >= 70 ? 'success' : 'warning',
};
});
const latencySamples = recentJobs
.filter((job) => job.processedAt)
.map((job) => job.processedAt!.getTime() - job.createdAt.getTime())
.filter((value) => Number.isFinite(value) && value >= 0);
const averageLatencyMs =
latencySamples.length > 0
? Math.round(latencySamples.reduce((sum, value) => sum + value, 0) / latencySamples.length)
: 120;
const processedLastHour = recentJobs.filter((job) => job.status === 'processed').length;
const webhookLastHour = recentWebhookEvents.length;
const verifiedWebhookRate =
totalWebhookEvents > 0 ? Math.round((verifiedWebhookEvents / totalWebhookEvents) * 100) : 0;
const throughputPerMinute = Math.max(1, Math.round((processedLastHour + webhookLastHour) / 60));
const averageAttempts =
recentJobs.length > 0
? Math.round(
recentJobs.reduce((sum, job) => sum + Math.min(job.attempts, job.maxAttempts), 0) /
recentJobs.length,
)
: 0;
const databaseConnectionsEstimate = Math.max(
1,
Math.min(200, recentJobs.length + recentWebhookEvents.length + Math.min(auditTrailTotal, 120)),
);
const memoryUsageGbEstimate = Number(
(1.8 + (recentJobs.length + recentWebhookEvents.length + pendingWebhookEvents) * 0.07).toFixed(1),
);
const apiLatencyBars = [
Math.max(4, Math.min(100, 32 + pendingJobs * 2)),
Math.max(4, Math.min(100, 42 + processingJobs * 8)),
Math.max(4, Math.min(100, 36 + failedJobs24h * 6)),
Math.max(4, Math.min(100, 56 + pendingWebhookEvents * 8)),
Math.max(4, Math.min(100, 28 + averageAttempts * 9)),
Math.max(4, Math.min(100, 22 + processingJobs * 7)),
Math.max(4, Math.min(100, 30 + verifiedWebhookRate)),
];
const memoryBars = [
Math.max(4, Math.min(100, 48 + pendingJobs * 3)),
Math.max(4, Math.min(100, 54 + processingJobs * 5)),
Math.max(4, Math.min(100, 58 + failedJobs24h * 4)),
Math.max(4, Math.min(100, 50 + pendingWebhookEvents * 7)),
Math.max(4, Math.min(100, 55 + verifiedWebhookRate)),
Math.max(4, Math.min(100, 62 + processingJobs * 4)),
Math.max(4, Math.min(100, 64 + averageAttempts * 5)),
];
return {
generatedAt: now.toISOString(),
queue: {
pendingJobs,
processingJobs,
failedJobs24h,
},
workers: workerHealth,
throughput: {
perMinute: throughputPerMinute,
verifiedWebhookRate,
jobsLastHour: recentJobs.length,
webhooksLastHour: webhookLastHour,
},
metrics: {
apiLatencyMs: averageLatencyMs,
apiLatencyBars,
databaseConnectionsEstimate,
databaseUsagePercent: Math.max(4, Math.min(100, Math.round((databaseConnectionsEstimate / 200) * 100))),
memoryUsageGbEstimate,
memoryBars,
},
totals: {
totalJobs,
totalWebhookEvents,
pendingWebhookEvents,
auditTrailTotal,
},
health: {
status: 'ok',
database: 'ok',
},
};
}
}

View File

@ -0,0 +1,50 @@
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateContactDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
name!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
phoneNumber!: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim().toLowerCase();
return trimmed || undefined;
})
@IsOptional()
@IsEmail()
email?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
company?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
notes?: string;
}

View File

@ -0,0 +1,81 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateContactDto {
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
@IsNotEmpty()
phoneNumber?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim().toLowerCase();
return trimmed || undefined;
})
@IsOptional()
@IsEmail()
email?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
company?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
notes?: string;
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
return value === 'true' || value === 'on';
})
@IsOptional()
@IsBoolean()
isBlacklisted?: boolean;
}

View File

@ -0,0 +1,23 @@
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async check() {
try {
await this.prisma.user.count({ take: 1 });
} catch {
throw new ServiceUnavailableException('Database is unavailable');
}
return {
status: 'ok',
service: 'wa-dashboard-backend',
database: 'ok',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { HealthController } from './health.controller';
@Module({
imports: [PrismaModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class TestWhatsappConfigDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsIn(['meta', 'qontak', 'default'])
provider?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
senderPhone?: string;
}

View File

@ -0,0 +1,63 @@
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { defaultWhatsappSubscriptions } from '../whatsapp-subscriptions';
export class UpdateWhatsappConfigDto {
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsIn(['meta', 'qontak', 'default'])
provider?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
webhookVerifyToken?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
sharedSecret?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
appSecret?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
accessToken?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
phoneNumberId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isEnabled?: boolean;
@IsOptional()
@Transform(({ value }) => {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
}
if (typeof value === 'string' && value.trim()) {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return defaultWhatsappSubscriptions;
})
@IsArray()
@IsIn(defaultWhatsappSubscriptions, { each: true })
subscriptions?: string[];
}

View File

@ -0,0 +1,34 @@
import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { IntegrationsService } from './integrations.service';
import { AuthGuard } from '../common/auth.guard';
import { TestWhatsappConfigDto } from './dto/test-whatsapp-config.dto';
import { UpdateWhatsappConfigDto } from './dto/update-whatsapp-config.dto';
@UseGuards(AuthGuard)
@Controller('integrations')
export class IntegrationsController {
constructor(private readonly integrationsService: IntegrationsService) {}
@Get('whatsapp')
getWhatsappSettings() {
return this.integrationsService.getWhatsappSettings();
}
@Patch('whatsapp')
updateWhatsappSettings(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: UpdateWhatsappConfigDto,
) {
return this.integrationsService.updateWhatsappSettings(dto, request.user, request.ip);
}
@Post('whatsapp/test')
testWhatsappSettings(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: TestWhatsappConfigDto,
) {
return this.integrationsService.testWhatsappSettings(dto, request.user, request.ip);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { IntegrationsController } from './integrations.controller';
import { IntegrationsService } from './integrations.service';
@Module({
imports: [AuthModule],
controllers: [IntegrationsController],
providers: [IntegrationsService],
})
export class IntegrationsModule {}

View File

@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthenticatedUser } from '../auth/auth.types';
import { getAppConfig } from '../config/env';
import { JobsService } from '../jobs/jobs.service';
import { PrismaService } from '../prisma/prisma.service';
import { TestWhatsappConfigDto } from './dto/test-whatsapp-config.dto';
import { UpdateWhatsappConfigDto } from './dto/update-whatsapp-config.dto';
import {
defaultWhatsappSubscriptions,
whatsappSubscriptionOptions,
} from './whatsapp-subscriptions';
@Injectable()
export class IntegrationsService {
constructor(
private readonly prisma: PrismaService,
private readonly jobsService: JobsService,
) {}
getWhatsappSettings() {
const config = getAppConfig();
return this.prisma.integrationConfig
.findUnique({
where: { configKey: 'whatsapp' },
})
.then((storedConfig) => {
const configJson = (storedConfig?.configJson as Record<string, unknown> | null) ?? {};
return {
provider: String(configJson.provider || storedConfig?.provider || 'meta'),
webhookUrl: `${config.publicApiUrl.replace(/\/$/, '')}/api/webhooks/whatsapp`,
verifyToken: String(configJson.webhookVerifyToken || config.webhookVerifyToken),
status: storedConfig ? (storedConfig.isEnabled ? 'configured' : 'disabled') : 'not-configured',
hasSharedSecret: Boolean(configJson.sharedSecret || config.webhookSharedSecret),
hasAppSecret: Boolean(configJson.appSecret || config.metaWebhookAppSecret),
hasAccessToken: Boolean(configJson.accessToken),
phoneNumberId: typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : '',
isEnabled: storedConfig?.isEnabled ?? true,
subscriptions: Array.isArray(configJson.subscriptions)
? configJson.subscriptions
: defaultWhatsappSubscriptions,
availableSubscriptions: whatsappSubscriptionOptions,
};
});
}
async updateWhatsappSettings(
dto: UpdateWhatsappConfigDto,
user: AuthenticatedUser,
ipAddress?: string,
) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const existing = await this.prisma.integrationConfig.findUnique({
where: { configKey: 'whatsapp' },
});
const previous = (existing?.configJson as Record<string, unknown> | null) ?? {};
const nextConfig = {
...previous,
...(dto.provider ? { provider: dto.provider } : {}),
...(dto.webhookVerifyToken ? { webhookVerifyToken: dto.webhookVerifyToken } : {}),
...(dto.sharedSecret ? { sharedSecret: dto.sharedSecret } : {}),
...(dto.appSecret !== undefined ? { appSecret: dto.appSecret } : {}),
...(dto.accessToken ? { accessToken: dto.accessToken } : {}),
...(dto.phoneNumberId ? { phoneNumberId: dto.phoneNumberId } : {}),
...(dto.subscriptions ? { subscriptions: dto.subscriptions } : {}),
};
const result = await this.prisma.integrationConfig.upsert({
where: { configKey: 'whatsapp' },
update: {
provider: dto.provider || existing?.provider || 'meta',
isEnabled: dto.isEnabled ?? existing?.isEnabled ?? true,
configJson: nextConfig as Prisma.InputJsonValue,
},
create: {
configKey: 'whatsapp',
provider: dto.provider || 'meta',
isEnabled: dto.isEnabled ?? true,
configJson: nextConfig as Prisma.InputJsonValue,
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'WhatsApp Settings Updated',
module: 'Integrations',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated WhatsApp settings for provider ${result.provider}.`,
metadataJson: nextConfig as Prisma.InputJsonValue,
},
});
return result;
}
async testWhatsappSettings(
dto: TestWhatsappConfigDto,
user: AuthenticatedUser,
ipAddress?: string,
) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
const provider = dto.provider || 'meta';
const senderPhone = dto.senderPhone || '6281999000111';
const eventId = `evt_test_${Date.now()}`;
await this.prisma.webhookEvent.create({
data: {
provider,
eventId,
eventType: 'message.inbound',
senderPhone,
recipientPhone: 'test-webhook-destination',
externalMessageId: eventId,
eventTimestamp: new Date(),
payloadJson: {
source: 'integration-test',
eventId,
provider,
senderPhone,
} as Prisma.InputJsonValue,
verified: true,
processingStatus: 'queued',
processingNotes: 'Queued from integration test endpoint',
},
});
const job = await this.jobsService.enqueue({
queueName: 'webhooks',
jobType: 'webhook.process',
payload: { eventId },
maxAttempts: 1,
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Webhook Test Queued',
module: 'Integrations',
ipAddress: ipAddress || null,
severity: 'default',
details: `Queued webhook test ${eventId} for provider ${provider}.`,
metadataJson: {
provider,
eventId,
senderPhone,
jobId: job.id,
} as Prisma.InputJsonValue,
},
});
return {
provider,
eventId,
jobId: job.id,
status: 'queued',
};
}
}

View File

@ -0,0 +1,41 @@
export const whatsappSubscriptionOptions = [
{
key: 'message.inbound',
label: 'messages',
description: 'Inbound customer messages',
},
{
key: 'message.delivered',
label: 'message_deliveries',
description: 'Delivery status updates',
},
{
key: 'message.read',
label: 'message_read',
description: 'Read receipt updates',
},
{
key: 'account.updated',
label: 'account_update',
description: 'WhatsApp account health or policy changes',
},
{
key: 'template.updated',
label: 'template_category_update',
description: 'Template review or category updates',
},
{
key: 'message.failed',
label: 'message_failed',
description: 'Failed message status updates',
},
{
key: 'message.sent',
label: 'message_sent',
description: 'Outbound sent acknowledgements',
},
] as const;
export const defaultWhatsappSubscriptions = whatsappSubscriptionOptions.map((item) => item.key);
export type WhatsappSubscriptionKey = (typeof whatsappSubscriptionOptions)[number]['key'];

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { JobsService } from './jobs.service';
import { RedisQueueService } from './redis-queue.service';
@Global()
@Module({
providers: [JobsService, RedisQueueService],
exports: [JobsService, RedisQueueService],
})
export class JobsModule {}

View File

@ -0,0 +1,129 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { RedisQueueService } from './redis-queue.service';
type EnqueueJobInput = {
queueName: string;
jobType: string;
payload: Prisma.InputJsonValue;
maxAttempts?: number;
availableAt?: Date;
};
@Injectable()
export class JobsService {
constructor(
private readonly prisma: PrismaService,
private readonly redisQueueService: RedisQueueService,
) {}
async enqueue(input: EnqueueJobInput) {
const job = await this.prisma.job.create({
data: {
queueName: input.queueName,
jobType: input.jobType,
payloadJson: input.payload,
maxAttempts: input.maxAttempts ?? 3,
availableAt: input.availableAt ?? new Date(),
},
});
await this.redisQueueService.enqueue(
input.queueName,
input.jobType,
{ dbJobId: job.id },
{
jobId: job.id,
delay: Math.max((job.availableAt.getTime() - Date.now()), 0),
attempts: input.maxAttempts ?? 3,
removeOnComplete: 500,
removeOnFail: 500,
},
);
return job;
}
async markProcessing(id: string) {
const claim = await this.prisma.job.updateMany({
where: {
id,
status: { in: ['queued', 'processing'] },
},
data: {
status: 'processing',
attempts: { increment: 1 },
},
});
if (claim.count === 0) {
return null;
}
return this.prisma.job.findUnique({ where: { id } });
}
findById(id: string) {
return this.prisma.job.findUnique({ where: { id } });
}
complete(id: string) {
return this.prisma.job.update({
where: { id },
data: {
status: 'processed',
processedAt: new Date(),
errorMessage: null,
},
});
}
async retry(id: string, errorMessage: string, delayMs: number) {
const job = await this.prisma.job.update({
where: { id },
data: {
status: 'queued',
availableAt: new Date(Date.now() + delayMs),
errorMessage: errorMessage.slice(0, 500),
},
});
await this.redisQueueService.enqueue(
job.queueName,
job.jobType,
{ dbJobId: job.id },
{
jobId: `${job.id}:retry:${job.attempts}`,
delay: Math.max(delayMs, 0),
attempts: Math.max(job.maxAttempts - job.attempts, 1),
removeOnComplete: 500,
removeOnFail: 500,
},
);
return job;
}
fail(id: string, errorMessage: string) {
return this.prisma.job.update({
where: { id },
data: {
status: 'failed',
failedAt: new Date(),
errorMessage: errorMessage.slice(0, 500),
},
});
}
findAll(limit = 50, queueName?: string, status?: string) {
return this.prisma.job.findMany({
where: {
queueName: queueName?.trim() || undefined,
status: status?.trim() || undefined,
},
orderBy: [{ createdAt: 'desc' }],
take: Math.min(Math.max(limit, 1), 100),
});
}
}

View File

@ -0,0 +1,69 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Queue, Worker, type JobsOptions, type Processor } from 'bullmq';
import IORedis from 'ioredis';
import { getAppConfig } from '../config/env';
@Injectable()
export class RedisQueueService implements OnModuleDestroy {
private readonly connection = new IORedis(getAppConfig().redisUrl, {
maxRetriesPerRequest: null,
});
private readonly queues = new Map<string, Queue>();
private readonly workers: Worker[] = [];
getQueue(name: string) {
const existing = this.queues.get(name);
if (existing) {
return existing;
}
const queue = new Queue(name, {
connection: this.connection,
});
this.queues.set(name, queue);
return queue;
}
async enqueue(name: string, jobName: string, data: Record<string, unknown>, options?: JobsOptions) {
const queue = this.getQueue(name);
await queue.add(jobName, data, options);
}
async getCounter(key: string) {
const value = await this.connection.get(key);
return value ? Number(value) : 0;
}
async getTtlSeconds(key: string) {
const ttl = await this.connection.ttl(key);
return ttl > 0 ? ttl : 0;
}
async incrementCounter(key: string, ttlSeconds: number) {
const nextCount = await this.connection.incr(key);
if (nextCount === 1) {
await this.connection.expire(key, ttlSeconds);
}
return nextCount;
}
async deleteKey(key: string) {
await this.connection.del(key);
}
createWorker(name: string, processor: Processor) {
const worker = new Worker(name, processor, {
connection: this.connection,
concurrency: 5,
});
this.workers.push(worker);
return worker;
}
async onModuleDestroy() {
await Promise.all(this.workers.map((worker) => worker.close()));
await Promise.all(Array.from(this.queues.values()).map((queue) => queue.close()));
await this.connection.quit();
}
}

View File

@ -0,0 +1,20 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAuditLogDto {
@IsString()
@IsNotEmpty()
actionType!: string;
@IsString()
@IsNotEmpty()
module!: string;
@IsString()
@IsNotEmpty()
details!: string;
@IsOptional()
@IsString()
@IsIn(['default', 'alert'])
severity?: 'default' | 'alert';
}

View File

@ -0,0 +1,95 @@
import { Body, Controller, Get, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { CreateAuditLogDto } from './dto/create-audit-log.dto';
import { LogsService } from './logs.service';
@UseGuards(AuthGuard)
@Controller('logs')
export class LogsController {
constructor(private readonly logsService: LogsService) {}
@Get('webhooks')
getWebhookLogs(
@Query('limit') limit?: string,
@Query('provider') provider?: string,
@Query('status') status?: string,
) {
const parsedLimit = limit ? Number(limit) : 50;
return this.logsService.getWebhookLogs(
Number.isFinite(parsedLimit) ? parsedLimit : 50,
provider,
status,
);
}
@Get('jobs')
getJobLogs(
@Query('limit') limit?: string,
@Query('queue') queueName?: string,
@Query('status') status?: string,
) {
const parsedLimit = limit ? Number(limit) : 50;
return this.logsService.getJobLogs(
Number.isFinite(parsedLimit) ? parsedLimit : 50,
queueName,
status,
);
}
@Get('audit-trail')
getAuditTrail(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('range') range?: string,
@Query('user') actorName?: string,
@Query('actionType') actionType?: string,
@Query('module') moduleName?: string,
@Query('search') search?: string,
) {
const parsedPage = page ? Number(page) : 1;
const parsedLimit = limit ? Number(limit) : 200;
return this.logsService.getAuditTrail(
Number.isFinite(parsedPage) ? parsedPage : 1,
Number.isFinite(parsedLimit) ? parsedLimit : 200,
range,
actorName,
actionType,
moduleName,
search,
);
}
@Get('audit-trail/export')
async exportAuditTrail(
@Res() response: Response,
@Query('range') range?: string,
@Query('user') actorName?: string,
@Query('actionType') actionType?: string,
@Query('module') moduleName?: string,
@Query('search') search?: string,
) {
const csv = await this.logsService.exportAuditTrailCsv(
range,
actorName,
actionType,
moduleName,
search,
);
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="audit-trail-export.csv"');
response.send(csv);
}
@Post('audit-trail')
createAuditTrailEntry(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateAuditLogDto,
) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.logsService.createAuditLog(dto, request.user, ipAddress);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { LogsController } from './logs.controller';
import { LogsService } from './logs.service';
@Module({
imports: [AuthModule, WebhooksModule],
controllers: [LogsController],
providers: [LogsService],
})
export class LogsModule {}

View File

@ -0,0 +1,181 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthenticatedUser } from '../auth/auth.types';
import { JobsService } from '../jobs/jobs.service';
import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CreateAuditLogDto } from './dto/create-audit-log.dto';
@Injectable()
export class LogsService {
constructor(
private readonly prisma: PrismaService,
private readonly jobsService: JobsService,
private readonly webhooksService: WebhooksService,
) {}
getWebhookLogs(limit?: number, provider?: string, status?: string) {
return this.webhooksService.findAll(limit, provider, status);
}
getJobLogs(limit?: number, queueName?: string, status?: string) {
return this.jobsService.findAll(limit, queueName, status);
}
getAuditTrail(
page = 1,
limit = 100,
range?: string,
actorName?: string,
actionType?: string,
moduleName?: string,
search?: string,
) {
const where: Prisma.AuditLogWhereInput = {
...(actorName ? { actorName } : {}),
...(actionType ? { actionType } : {}),
...(moduleName ? { module: moduleName } : {}),
};
const trimmedSearch = search?.trim();
if (trimmedSearch) {
where.OR = [
{ actorName: { contains: trimmedSearch, mode: 'insensitive' } },
{ actorEmail: { contains: trimmedSearch, mode: 'insensitive' } },
{ actionType: { contains: trimmedSearch, mode: 'insensitive' } },
{ module: { contains: trimmedSearch, mode: 'insensitive' } },
{ details: { contains: trimmedSearch, mode: 'insensitive' } },
{ ipAddress: { contains: trimmedSearch, mode: 'insensitive' } },
];
}
const createdAt = this.resolveCreatedAtRange(range);
if (createdAt) {
where.createdAt = createdAt;
}
const take = Math.min(Math.max(limit, 1), 500);
const skip = Math.max(page - 1, 0) * take;
return this.prisma.$transaction(async (tx) => {
const [items, total] = await Promise.all([
tx.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
}),
tx.auditLog.count({ where }),
]);
return {
items,
total,
page,
pageSize: take,
totalPages: Math.max(1, Math.ceil(total / take)),
};
});
}
async exportAuditTrailCsv(
range?: string,
actorName?: string,
actionType?: string,
moduleName?: string,
search?: string,
) {
const result = await this.getAuditTrail(1, 5000, range, actorName, actionType, moduleName, search);
const header = ['Timestamp', 'Admin User', 'Admin Email', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details'];
const rows = result.items.map((item) =>
[
item.createdAt.toISOString(),
item.actorName,
item.actorEmail || '',
item.actionType,
item.module,
item.ipAddress || '',
item.severity,
item.details,
]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
.join(','),
);
return [header.join(','), ...rows].join('\n');
}
async createAuditLog(
dto: CreateAuditLogDto,
user: AuthenticatedUser,
ipAddress?: string,
) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
return this.prisma.auditLog.create({
data: {
actorUserId: actor?.id,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: dto.actionType,
module: dto.module,
ipAddress: ipAddress || null,
severity: dto.severity || 'default',
details: dto.details,
},
});
}
createSystemAuditLog(input: {
actorUserId?: string | null;
actorName: string;
actorEmail?: string | null;
actionType: string;
module: string;
ipAddress?: string | null;
severity?: 'default' | 'alert';
details: string;
metadataJson?: Prisma.InputJsonValue;
}) {
return this.prisma.auditLog.create({
data: {
actorUserId: input.actorUserId || null,
actorName: input.actorName,
actorEmail: input.actorEmail || null,
actionType: input.actionType,
module: input.module,
ipAddress: input.ipAddress || null,
severity: input.severity || 'default',
details: input.details,
metadataJson: input.metadataJson,
},
});
}
private resolveCreatedAtRange(range?: string): Prisma.DateTimeFilter | undefined {
if (!range || range === 'all') {
return undefined;
}
const now = Date.now();
const offset =
range === '24h'
? 24 * 60 * 60 * 1000
: range === '7d'
? 7 * 24 * 60 * 60 * 1000
: range === '30d'
? 30 * 24 * 60 * 60 * 1000
: null;
if (!offset) {
return undefined;
}
return {
gte: new Date(now - offset),
};
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';
@Module({
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {}

View File

@ -0,0 +1,172 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { getAppConfig } from '../config/env';
@Injectable()
export class MailerService {
private readonly logger = new Logger(MailerService.name);
private readonly config = getAppConfig();
private createTransport() {
if (!this.config.mailHost || !this.config.mailUser || !this.config.mailPassword) {
return null;
}
return nodemailer.createTransport({
host: this.config.mailHost,
port: this.config.mailPort,
secure: this.config.mailSecure,
auth: {
user: this.config.mailUser,
pass: this.config.mailPassword,
},
});
}
async sendInvitationEmail(input: {
to: string;
name: string;
roleName: string;
invitationUrl: string;
invitedBy: string;
}) {
const transporter = this.createTransport();
if (!transporter) {
this.logger.warn(`SMTP config missing, invitation email skipped for ${input.to}`);
return { delivered: false };
}
await transporter.sendMail({
from: this.config.mailFrom,
to: input.to,
subject: 'Set up your BizOne account',
text: [
`Hello ${input.name},`,
'',
`${input.invitedBy} invited you to BizOne as ${input.roleName}.`,
'Open the link below to verify your email and create your password:',
input.invitationUrl,
'',
'This invitation link will expire in 24 hours.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f2937">
<h2 style="margin:0 0 16px;color:#006d2f">BizOne Account Invitation</h2>
<p>Hello ${escapeHtml(input.name)},</p>
<p><strong>${escapeHtml(input.invitedBy)}</strong> invited you to BizOne as <strong>${escapeHtml(input.roleName)}</strong>.</p>
<p>Click the button below to verify your email and create your password.</p>
<p style="margin:24px 0">
<a href="${input.invitationUrl}" style="background:#006d2f;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;display:inline-block;font-weight:700">
Set Password
</a>
</p>
<p>If the button does not work, open this link manually:</p>
<p><a href="${input.invitationUrl}">${input.invitationUrl}</a></p>
<p>This invitation link will expire in 24 hours.</p>
</div>
`,
});
return { delivered: true };
}
async sendPasswordResetEmail(input: {
to: string;
name: string;
resetUrl: string;
}) {
const transporter = this.createTransport();
if (!transporter) {
this.logger.warn(`SMTP config missing, password reset email skipped for ${input.to}`);
return { delivered: false };
}
await transporter.sendMail({
from: this.config.mailFrom,
to: input.to,
subject: 'Reset your BizOne password',
text: [
`Hello ${input.name},`,
'',
'We received a request to reset your BizOne password.',
'Open the link below to choose a new password:',
input.resetUrl,
'',
'This reset link will expire in 1 hour.',
'If you did not request this, you can ignore this email.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f2937">
<h2 style="margin:0 0 16px;color:#006d2f">BizOne Password Reset</h2>
<p>Hello ${escapeHtml(input.name)},</p>
<p>We received a request to reset your BizOne password.</p>
<p>Click the button below to choose a new password.</p>
<p style="margin:24px 0">
<a href="${input.resetUrl}" style="background:#006d2f;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;display:inline-block;font-weight:700">
Reset Password
</a>
</p>
<p>If the button does not work, open this link manually:</p>
<p><a href="${input.resetUrl}">${input.resetUrl}</a></p>
<p>This reset link will expire in 1 hour. If you did not request this, you can ignore this email.</p>
</div>
`,
});
return { delivered: true };
}
async sendSecurityNotificationEmail(input: {
to: string;
name: string;
subject: string;
heading: string;
intro: string;
bullets: string[];
note?: string;
}) {
const transporter = this.createTransport();
if (!transporter) {
this.logger.warn(`SMTP config missing, security notification email skipped for ${input.to}`);
return { delivered: false };
}
const bulletText = input.bullets.map((item) => `- ${item}`).join('\n');
const bulletHtml = input.bullets.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
await transporter.sendMail({
from: this.config.mailFrom,
to: input.to,
subject: input.subject,
text: [
`Hello ${input.name},`,
'',
input.intro,
'',
bulletText,
'',
input.note || 'If this was not expected, review your account security immediately.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f2937">
<h2 style="margin:0 0 16px;color:#006d2f">${escapeHtml(input.heading)}</h2>
<p>Hello ${escapeHtml(input.name)},</p>
<p>${escapeHtml(input.intro)}</p>
<ul style="padding-left:20px">${bulletHtml}</ul>
<p>${escapeHtml(input.note || 'If this was not expected, review your account security immediately.')}</p>
</div>
`,
});
return { delivered: true };
}
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

36
backend/src/main.ts Normal file
View File

@ -0,0 +1,36 @@
import 'reflect-metadata';
import { json } from 'express';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { PrismaExceptionFilter } from './common/prisma-exception.filter';
import { getAppConfig } from './config/env';
import { AppModule } from './app.module';
async function bootstrap() {
const config = getAppConfig();
const app = await NestFactory.create(AppModule);
app.use(
json({
verify: (request: { rawBody?: Buffer } & object, _response, buffer) => {
request.rawBody = Buffer.from(buffer);
},
}),
);
app.setGlobalPrefix('api');
app.getHttpAdapter().getInstance().disable('x-powered-by');
app.enableCors({
origin: config.corsOrigins,
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new PrismaExceptionFilter());
app.enableShutdownHooks();
await app.listen(config.port);
}
bootstrap();

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,25 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getAppConfig } from '../config/env';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const config = getAppConfig();
super({
datasources: {
db: {
url: config.databaseUrl,
},
},
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,33 @@
import { IsArray, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateRoleDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
summary?: string;
@IsString()
@IsOptional()
badge?: string;
@IsString()
@IsOptional()
@IsIn(['primary', 'secondary', 'tertiary'])
tone?: 'primary' | 'secondary' | 'tertiary';
@IsString()
@IsOptional()
icon?: string;
@IsArray()
permissions!: Array<{
id: string;
label: string;
icon: string;
description: string;
values: Record<string, boolean | null>;
}>;
}

View File

@ -0,0 +1,35 @@
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateRoleDto {
@IsString()
@IsOptional()
@IsNotEmpty()
name?: string;
@IsString()
@IsOptional()
summary?: string;
@IsString()
@IsOptional()
badge?: string;
@IsString()
@IsOptional()
@IsIn(['primary', 'secondary', 'tertiary'])
tone?: 'primary' | 'secondary' | 'tertiary';
@IsString()
@IsOptional()
icon?: string;
@IsArray()
@IsOptional()
permissions?: Array<{
id: string;
label: string;
icon: string;
description: string;
values: Record<string, boolean | null>;
}>;
}

View File

@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { RequirePermission } from '../common/permission.decorator';
import { PermissionGuard } from '../common/permission.guard';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { RolesService } from './roles.service';
@UseGuards(AuthGuard, PermissionGuard)
@Controller('roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@RequirePermission('roles', 'view')
findAll() {
return this.rolesService.findAll();
}
@Post()
@RequirePermission('roles', 'manage')
create(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateRoleDto,
) {
return this.rolesService.create(dto, request.user, request.ip);
}
@Patch(':id')
@RequirePermission('roles', 'manage')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateRoleDto,
) {
return this.rolesService.update(id, dto, request.user, request.ip);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
@Module({
imports: [AuthModule],
controllers: [RolesController],
providers: [RolesService],
exports: [RolesService],
})
export class RolesModule {}

View File

@ -0,0 +1,242 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeText } from '../common/normalize';
import { PrismaService } from '../prisma/prisma.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
type PermissionRow = {
id: string;
label: string;
icon: string;
description: string;
values: Record<string, boolean | null>;
};
const defaultRoles: Array<{
key: string;
name: string;
summary: string;
badge: string;
tone: 'primary' | 'secondary' | 'tertiary';
icon: string;
permissions: PermissionRow[];
}> = [
{
key: 'admin',
name: 'Admin',
summary: 'Full access to all modules, security controls, system configuration, and account-wide actions.',
badge: 'Active',
tone: 'primary',
icon: 'shield_person',
permissions: [
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: true, delete: true, manage: true } },
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: true } },
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: true, edit: true, delete: true, manage: true } },
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: true, edit: null, delete: null, manage: true } },
],
},
{
key: 'editor',
name: 'Editor',
summary: 'Can create templates and manage campaigns, but cannot change security policy or critical settings.',
badge: 'Standard',
tone: 'secondary',
icon: 'edit_document',
permissions: [
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: true, delete: false, manage: false } },
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } },
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } },
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } },
],
},
{
key: 'agent',
name: 'Agent',
summary: 'Focused access for daily operations: conversations, contact handling, and high-signal analytics visibility.',
badge: 'Standard',
tone: 'tertiary',
icon: 'support_agent',
permissions: [
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: false, delete: false, manage: false } },
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } },
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } },
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } },
],
},
];
@Injectable()
export class RolesService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
await this.ensureDefaultRoles();
const roles = await this.prisma.role.findMany({
include: {
_count: {
select: { users: true },
},
},
orderBy: { createdAt: 'asc' },
});
return roles.map((role) => ({
id: role.id,
key: role.key,
name: role.name,
summary: role.summary,
badge: role.badge,
tone: role.tone,
icon: role.icon,
usersAssigned: role._count.users,
permissions: role.permissionsJson,
}));
}
async create(dto: CreateRoleDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureDefaultRoles();
const actor = await this.findActor(user.sub, user.email);
const name = normalizeText(dto.name)!;
const key = this.slugify(name);
const role = await this.prisma.role.create({
data: {
key,
name,
summary: normalizeText(dto.summary) || 'Custom role for a focused operational access policy.',
badge: normalizeText(dto.badge) || 'Custom',
tone: dto.tone || 'secondary',
icon: normalizeText(dto.icon) || 'verified_user',
permissionsJson: dto.permissions as Prisma.InputJsonValue,
},
include: {
_count: { select: { users: true } },
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Role Created',
module: 'Access Control',
ipAddress: ipAddress || null,
severity: 'default',
details: `Created role ${role.name}.`,
},
});
return {
id: role.id,
key: role.key,
name: role.name,
summary: role.summary,
badge: role.badge,
tone: role.tone,
icon: role.icon,
usersAssigned: role._count.users,
permissions: role.permissionsJson,
};
}
async update(id: string, dto: UpdateRoleDto, user: AuthenticatedUser, ipAddress?: string) {
const actor = await this.findActor(user.sub, user.email);
const role = await this.prisma.role.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: normalizeText(dto.name)! } : {}),
...(dto.summary !== undefined ? { summary: normalizeText(dto.summary) || '' } : {}),
...(dto.badge !== undefined ? { badge: normalizeText(dto.badge) || 'Custom' } : {}),
...(dto.tone !== undefined ? { tone: dto.tone } : {}),
...(dto.icon !== undefined ? { icon: normalizeText(dto.icon) || 'verified_user' } : {}),
...(dto.permissions !== undefined
? { permissionsJson: dto.permissions as Prisma.InputJsonValue }
: {}),
},
include: {
_count: { select: { users: true } },
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'User Role Updated',
module: 'Access Control',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated role ${role.name}.`,
},
});
return {
id: role.id,
key: role.key,
name: role.name,
summary: role.summary,
badge: role.badge,
tone: role.tone,
icon: role.icon,
usersAssigned: role._count.users,
permissions: role.permissionsJson,
};
}
private async ensureDefaultRoles() {
const count = await this.prisma.role.count();
if (count > 0) {
return;
}
for (const role of defaultRoles) {
await this.prisma.role.create({
data: {
key: role.key,
name: role.name,
summary: role.summary,
badge: role.badge,
tone: role.tone,
icon: role.icon,
permissionsJson: role.permissions as Prisma.InputJsonValue,
},
});
}
const adminRole = await this.prisma.role.findUnique({ where: { key: 'admin' } });
if (adminRole) {
await this.prisma.user.updateMany({
where: { roleId: null },
data: { roleId: adminRole.id },
});
}
}
private async findActor(userId: string, email: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return {
id: actor?.id || userId,
name: actor?.name || email,
email: actor?.email || email,
};
}
private slugify(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || `role-${Date.now()}`
);
}
}

View File

@ -0,0 +1,47 @@
import { Transform } from 'class-transformer';
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateTemplateDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(120)
name!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
category!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
status!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
language!: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
headerText?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
bodyText!: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
footerText?: string;
@IsOptional()
@IsArray()
buttons?: Array<{ type: string; label: string }>;
}

View File

@ -0,0 +1,52 @@
import { Transform } from 'class-transformer';
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateTemplateDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
category?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
status?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
language?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
headerText?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
bodyText?: string;
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed || undefined;
})
@IsOptional()
@IsString()
footerText?: string;
@IsOptional()
@IsArray()
buttons?: Array<{ type: string; label: string }>;
}

View File

@ -0,0 +1,51 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { PermissionGuard } from '../common/permission.guard';
import { RequirePermission } from '../common/permission.decorator';
import { CreateTemplateDto } from './dto/create-template.dto';
import { UpdateTemplateDto } from './dto/update-template.dto';
import { TemplatesService } from './templates.service';
@UseGuards(AuthGuard, PermissionGuard)
@Controller('templates')
export class TemplatesController {
constructor(private readonly templatesService: TemplatesService) {}
@Get()
@RequirePermission('templates', 'view')
findAll(
@Query('search') search?: string,
@Query('category') category?: string,
@Query('status') status?: string,
@Query('language') language?: string,
) {
return this.templatesService.findAll({ search, category, status, language });
}
@Get(':id')
@RequirePermission('templates', 'view')
findOne(@Param('id') id: string) {
return this.templatesService.findOne(id);
}
@Post()
@RequirePermission('templates', 'edit')
create(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateTemplateDto,
) {
return this.templatesService.create(dto, request.user, request.ip);
}
@Patch(':id')
@RequirePermission('templates', 'edit')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateTemplateDto,
) {
return this.templatesService.update(id, dto, request.user, request.ip);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TemplatesController } from './templates.controller';
import { TemplatesService } from './templates.service';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [TemplatesController],
providers: [TemplatesService],
exports: [TemplatesService],
})
export class TemplatesModule {}

View File

@ -0,0 +1,341 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeText } from '../common/normalize';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTemplateDto } from './dto/create-template.dto';
import { UpdateTemplateDto } from './dto/update-template.dto';
type SeedTemplate = {
name: string;
category: string;
status: string;
language: string;
headerText?: string;
bodyText: string;
footerText?: string;
buttons?: Array<{ type: string; label: string }>;
};
const seededTemplates: SeedTemplate[] = [
{
name: 'order_confirmation_v2',
category: 'Utility',
status: 'Approved',
language: 'en_US',
bodyText: "Hi {{1}}, thank you for your order #{{2}}! We've received your payment and will notify you when it ships...",
footerText: 'Reply STOP to opt out.',
buttons: [{ type: 'quick_reply', label: 'Track Order' }],
},
{
name: 'holiday_sale_promo',
category: 'Marketing',
status: 'Pending',
language: 'en_US',
headerText: 'Holiday Sale',
bodyText: 'Exclusive Holiday Sale! Get 30% OFF on all items using code FESTIVE30. Shop now at {{1}}...',
footerText: 'Valid through Sunday only.',
buttons: [{ type: 'quick_reply', label: 'Shop Now' }],
},
{
name: 'account_recovery_otp',
category: 'Authentication',
status: 'Rejected',
language: 'en_US',
bodyText: 'Your recovery code is {{1}}. Do not share this with anyone. This code expires in 5 minutes.',
footerText: 'Security automation',
},
{
name: 'shipping_update_express',
category: 'Utility',
status: 'Approved',
language: 'en_US',
bodyText: 'Good news! Your package is out for delivery and should arrive before 7 PM today.',
buttons: [{ type: 'quick_reply', label: 'View Delivery' }],
},
];
@Injectable()
export class TemplatesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(params?: { search?: string; category?: string; status?: string; language?: string }) {
await this.ensureSeedData();
const search = params?.search?.trim();
const category = params?.category?.trim();
const status = params?.status?.trim();
const language = params?.language?.trim();
const where: Prisma.MessageTemplateWhereInput = {
...(category ? { category: { equals: category, mode: 'insensitive' } } : {}),
...(status ? { status: { equals: status, mode: 'insensitive' } } : {}),
...(language ? { language: { equals: language, mode: 'insensitive' } } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ category: { contains: search, mode: 'insensitive' } },
{ bodyText: { contains: search, mode: 'insensitive' } },
],
}
: {}),
};
const templates = await this.prisma.messageTemplate.findMany({
where,
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
});
return {
total: templates.length,
items: templates.map((template) => this.serializeListItem(template)),
};
}
async findOne(id: string) {
await this.ensureSeedData();
const template = await this.prisma.messageTemplate.findUnique({ where: { id } });
if (!template) {
throw new NotFoundException('Template not found');
}
return this.serializeDetail(template);
}
async create(dto: CreateTemplateDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
await this.assertUniqueName(dto.name);
const actor = await this.findActor(user.sub, user.email);
const template = await this.prisma.messageTemplate.create({
data: {
name: normalizeText(dto.name)!,
category: normalizeText(dto.category) || 'Utility',
status: normalizeText(dto.status) || 'Draft',
language: normalizeText(dto.language) || 'en_US',
headerText: normalizeText(dto.headerText),
bodyText: normalizeText(dto.bodyText) || '',
footerText: normalizeText(dto.footerText),
buttonsJson: this.normalizeButtons(dto.buttons),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Template Created',
module: 'Templates',
ipAddress: ipAddress || null,
severity: 'default',
details: `Created template ${template.name}.`,
},
});
return this.serializeDetail(template);
}
async update(id: string, dto: UpdateTemplateDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
const current = await this.prisma.messageTemplate.findUnique({ where: { id } });
if (!current) {
throw new NotFoundException('Template not found');
}
if (dto.name && dto.name.trim().toLowerCase() !== current.name.toLowerCase()) {
await this.assertUniqueName(dto.name);
}
const actor = await this.findActor(user.sub, user.email);
const template = await this.prisma.messageTemplate.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: normalizeText(dto.name)! } : {}),
...(dto.category !== undefined ? { category: normalizeText(dto.category) || current.category } : {}),
...(dto.status !== undefined ? { status: normalizeText(dto.status) || current.status } : {}),
...(dto.language !== undefined ? { language: normalizeText(dto.language) || current.language } : {}),
...(dto.headerText !== undefined ? { headerText: normalizeText(dto.headerText) } : {}),
...(dto.bodyText !== undefined ? { bodyText: normalizeText(dto.bodyText) || current.bodyText } : {}),
...(dto.footerText !== undefined ? { footerText: normalizeText(dto.footerText) } : {}),
...(dto.buttons !== undefined ? { buttonsJson: this.normalizeButtons(dto.buttons) } : {}),
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Template Updated',
module: 'Templates',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated template ${template.name}.`,
},
});
return this.serializeDetail(template);
}
async assertTemplateExistsByName(name: string | null | undefined) {
if (!name?.trim()) {
return;
}
const template = await this.prisma.messageTemplate.findFirst({
where: {
name: { equals: name.trim(), mode: 'insensitive' },
},
select: { id: true },
});
if (!template) {
throw new NotFoundException(`Template "${name}" not found`);
}
}
private serializeListItem(template: {
id: string;
name: string;
category: string;
status: string;
language: string;
headerText: string | null;
bodyText: string;
footerText: string | null;
buttonsJson: Prisma.JsonValue | null;
updatedAt: Date;
}) {
const buttons = this.readButtons(template.buttonsJson);
const preview = template.bodyText.length > 120 ? `${template.bodyText.slice(0, 117)}...` : template.bodyText;
return {
id: template.id,
name: template.name,
category: template.category,
status: template.status,
language: template.language,
updatedAt: template.updatedAt.toISOString(),
updatedLabel: this.relativeTimeLabel(template.updatedAt),
preview,
compact: buttons.length > 0,
buttons,
};
}
private serializeDetail(template: {
id: string;
name: string;
category: string;
status: string;
language: string;
headerText: string | null;
bodyText: string;
footerText: string | null;
buttonsJson: Prisma.JsonValue | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: template.id,
name: template.name,
category: template.category,
status: template.status,
language: template.language,
headerText: template.headerText,
bodyText: template.bodyText,
footerText: template.footerText,
buttons: this.readButtons(template.buttonsJson),
createdAt: template.createdAt.toISOString(),
updatedAt: template.updatedAt.toISOString(),
};
}
private normalizeButtons(buttons?: Array<{ type: string; label: string }>) {
if (!Array.isArray(buttons)) {
return Prisma.JsonNull;
}
return buttons
.map((button) => ({
type: normalizeText(button.type) || 'quick_reply',
label: normalizeText(button.label) || '',
}))
.filter((button) => button.label.length > 0) as Prisma.InputJsonValue;
}
private readButtons(value: Prisma.JsonValue | null) {
if (!Array.isArray(value)) {
return [] as Array<{ type: string; label: string }>;
}
return value
.map((item) => (item && typeof item === 'object' ? (item as Prisma.JsonObject) : null))
.filter((item): item is Prisma.JsonObject => Boolean(item))
.map((item) => ({
type: typeof item.type === 'string' ? item.type : 'quick_reply',
label: typeof item.label === 'string' ? item.label : '',
}))
.filter((button) => button.label.length > 0);
}
private async assertUniqueName(name: string) {
const existing = await this.prisma.messageTemplate.findFirst({
where: { name: { equals: name.trim(), mode: 'insensitive' } },
select: { id: true },
});
if (existing) {
throw new ConflictException('Template name already exists');
}
}
private async ensureSeedData() {
const count = await this.prisma.messageTemplate.count();
if (count > 0) {
return;
}
for (const template of seededTemplates) {
await this.prisma.messageTemplate.create({
data: {
name: template.name,
category: template.category,
status: template.status,
language: template.language,
headerText: template.headerText || null,
bodyText: template.bodyText,
footerText: template.footerText || null,
buttonsJson: template.buttons ? (template.buttons as Prisma.InputJsonValue) : Prisma.JsonNull,
},
});
}
}
private async findActor(userId: string, email: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return {
id: actor?.id || userId,
name: actor?.name || email,
email: actor?.email || email,
};
}
private relativeTimeLabel(date: Date) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(1, Math.floor(diffMs / 60000));
if (diffMinutes < 60) return `Updated ${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `Updated ${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `Updated ${diffDays}d ago`;
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
}
}

1
backend/src/types/bcryptjs.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'bcryptjs';

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CompleteInvitationDto {
@IsString()
@IsNotEmpty()
@MinLength(8)
password!: string;
}

View File

@ -0,0 +1,14 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsEmail()
email!: string;
@IsString()
@IsOptional()
roleId?: string;
}

View File

@ -0,0 +1,20 @@
import { IsEmail, IsIn, IsOptional, IsString } from 'class-validator';
export class UpdateUserDto {
@IsString()
@IsOptional()
name?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
roleId?: string;
@IsString()
@IsOptional()
@IsIn(['invited', 'active', 'inactive', 'suspended'])
status?: 'invited' | 'active' | 'inactive' | 'suspended';
}

View File

@ -0,0 +1,84 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { RequirePermission } from '../common/permission.decorator';
import { PermissionGuard } from '../common/permission.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@UseGuards(AuthGuard, PermissionGuard)
@Get()
@RequirePermission('users', 'view')
findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('roleId') roleId?: string,
@Query('status') status?: string,
) {
return this.usersService.findAll({
page: page ? Number(page) : 1,
limit: limit ? Number(limit) : 10,
search,
roleId,
status,
});
}
@UseGuards(AuthGuard, PermissionGuard)
@Get('export')
@RequirePermission('users', 'view')
async exportCsv(
@Res() response: Response,
@Query('search') search?: string,
@Query('roleId') roleId?: string,
@Query('status') status?: string,
) {
const csv = await this.usersService.exportCsv({ search, roleId, status });
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="users-export.csv"');
response.send(csv);
}
@UseGuards(AuthGuard, PermissionGuard)
@Post('invite')
@RequirePermission('users', 'manage')
invite(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateUserDto,
) {
return this.usersService.invite(dto, request.user, request.ip);
}
@UseGuards(AuthGuard, PermissionGuard)
@Patch(':id')
@RequirePermission('users', 'manage')
update(
@Req() request: Request & { user: AuthenticatedUser },
@Param('id') id: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto, request.user, request.ip);
}
@Get('invitations/:token')
getInvitation(@Param('token') token: string) {
return this.usersService.getInvitation(token);
}
@Post('invitations/:token/complete')
completeInvitation(
@Req() request: Request,
@Param('token') token: string,
@Body() dto: CompleteInvitationDto,
) {
return this.usersService.completeInvitation(token, dto, request.ip);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MailerModule } from '../mailer/mailer.module';
import { PrismaModule } from '../prisma/prisma.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [PrismaModule, MailerModule, AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,380 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomBytes } from 'node:crypto';
import { AuthenticatedUser } from '../auth/auth.types';
import { normalizeEmail, normalizeText } from '../common/normalize';
import { hasMinimumPasswordLength, hashPassword } from '../common/password';
import { getAppConfig } from '../config/env';
import { MailerService } from '../mailer/mailer.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { CompleteInvitationDto } from './dto/complete-invitation.dto';
const config = getAppConfig();
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly mailer: MailerService,
) {}
async findAll(params?: {
page?: number;
limit?: number;
search?: string;
roleId?: string;
status?: string;
}) {
const page = Math.max(1, params?.page || 1);
const limit = Math.min(50, Math.max(1, params?.limit || 10));
const where = this.buildWhere(params);
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: {
role: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.user.count({ where }),
]);
return {
items: users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
emailVerifiedAt: user.emailVerifiedAt,
})),
total,
page,
pageSize: limit,
totalPages: Math.max(1, Math.ceil(total / limit)),
};
}
async exportCsv(params?: {
search?: string;
roleId?: string;
status?: string;
}) {
const users = await this.prisma.user.findMany({
where: this.buildWhere(params),
include: {
role: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
});
const header = [
'Name',
'Email',
'Role',
'Status',
'Created At',
'Updated At',
'Last Login At',
'Email Verified At',
];
const rows = users.map((user) =>
[
user.name,
user.email,
user.role?.name || 'Unassigned',
user.status,
user.createdAt.toISOString(),
user.updatedAt.toISOString(),
user.lastLoginAt?.toISOString() || '',
user.emailVerifiedAt?.toISOString() || '',
]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
.join(','),
);
return [header.join(','), ...rows].join('\n');
}
async invite(dto: CreateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
const actor = await this.findActor(actorUser.sub, actorUser.email);
const email = normalizeEmail(dto.email);
const name = normalizeText(dto.name);
if (!name) {
throw new BadRequestException('Name is required');
}
const role = dto.roleId
? await this.prisma.role.findUnique({ where: { id: dto.roleId } })
: await this.prisma.role.findUnique({ where: { key: 'agent' } });
if (dto.roleId && !role) {
throw new NotFoundException('Role not found');
}
const existingUser = await this.prisma.user.findUnique({
where: { email },
select: { id: true, status: true },
});
if (existingUser?.status === 'active') {
throw new BadRequestException('User already exists and is active');
}
const token = randomBytes(24).toString('hex');
const tokenHash = this.hashToken(token);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const user = await this.prisma.user.upsert({
where: { email },
update: {
name,
roleId: role?.id || null,
status: 'invited',
inviteTokenHash: tokenHash,
inviteTokenExpiresAt: expiresAt,
passwordHash: null,
emailVerifiedAt: null,
},
create: {
name,
email,
roleId: role?.id || null,
status: 'invited',
inviteTokenHash: tokenHash,
inviteTokenExpiresAt: expiresAt,
},
include: {
role: {
select: { name: true },
},
},
});
const invitationUrl = `${config.frontendOrigin}/invite/${token}`;
let mailResult: { delivered: boolean } = { delivered: false };
try {
mailResult = await this.mailer.sendInvitationEmail({
to: user.email,
name: user.name,
roleName: user.role?.name || 'User',
invitationUrl,
invitedBy: actor.name,
});
} catch {
mailResult = { delivered: false };
}
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'User Invited',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Invited user ${user.email} as ${user.role?.name || 'Unassigned'}.`,
},
});
return {
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
invitationUrl,
emailSent: mailResult.delivered,
};
}
async update(id: string, dto: UpdateUserDto, actorUser: AuthenticatedUser, ipAddress?: string) {
const actor = await this.findActor(actorUser.sub, actorUser.email);
if (dto.roleId) {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) {
throw new NotFoundException('Role not found');
}
}
const user = await this.prisma.user.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: normalizeText(dto.name) || '' } : {}),
...(dto.email !== undefined ? { email: normalizeEmail(dto.email) } : {}),
...(dto.roleId !== undefined ? { roleId: dto.roleId || null } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
},
include: {
role: {
select: { name: true },
},
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'User Updated',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated user ${user.email}.`,
},
});
return {
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleId: user.roleId,
roleName: user.role?.name || 'Unassigned',
lastLoginAt: user.lastLoginAt,
emailVerifiedAt: user.emailVerifiedAt,
};
}
async getInvitation(token: string) {
const invite = await this.findInvitationByToken(token);
return {
email: invite.email,
name: invite.name,
roleName: invite.role?.name || 'User',
expiresAt: invite.inviteTokenExpiresAt,
};
}
async completeInvitation(token: string, dto: CompleteInvitationDto, ipAddress?: string) {
if (!hasMinimumPasswordLength(dto.password)) {
throw new BadRequestException('Password must be at least 8 characters');
}
const invite = await this.findInvitationByToken(token);
const passwordHash = await hashPassword(dto.password);
const user = await this.prisma.user.update({
where: { id: invite.id },
data: {
passwordHash,
status: 'active',
emailVerifiedAt: new Date(),
inviteTokenHash: null,
inviteTokenExpiresAt: null,
},
include: {
role: {
select: { name: true },
},
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: user.id,
actorName: user.name,
actorEmail: user.email,
actionType: 'User Activated',
module: 'User Management',
ipAddress: ipAddress || null,
severity: 'default',
details: `Completed password setup for ${user.email}.`,
},
});
return {
id: user.id,
email: user.email,
status: user.status,
};
}
private async findInvitationByToken(token: string) {
const invite = await this.prisma.user.findFirst({
where: {
inviteTokenHash: this.hashToken(token),
},
include: {
role: {
select: { name: true },
},
},
});
if (!invite || !invite.inviteTokenExpiresAt || invite.inviteTokenExpiresAt.getTime() < Date.now()) {
throw new UnauthorizedException('Invitation token is invalid or expired');
}
return invite;
}
private async findActor(userId: string, email: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return {
id: actor?.id || userId,
name: actor?.name || email,
email: actor?.email || email,
};
}
private hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
private buildWhere(params?: {
search?: string;
roleId?: string;
status?: string;
}): Prisma.UserWhereInput {
const search = params?.search?.trim();
const roleId = params?.roleId?.trim();
const status = params?.status?.trim();
const and: Prisma.UserWhereInput[] = [];
if (search) {
and.push({
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
});
}
if (roleId) {
and.push({ roleId });
}
if (status && ['invited', 'active', 'inactive', 'suspended'].includes(status)) {
and.push({ status: status as 'invited' | 'active' | 'inactive' | 'suspended' });
}
return and.length > 0 ? { AND: and } : {};
}
}

View File

@ -0,0 +1,42 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { Job as BullJob, Worker } from 'bullmq';
import { JobsService } from '../jobs/jobs.service';
import { RedisQueueService } from '../jobs/redis-queue.service';
import { WebhooksService } from './webhooks.service';
@Injectable()
export class WebhookWorkerService implements OnModuleInit, OnModuleDestroy {
private worker: Worker | null = null;
constructor(
private readonly jobsService: JobsService,
private readonly redisQueueService: RedisQueueService,
private readonly webhooksService: WebhooksService,
) {}
onModuleInit() {
this.worker = this.redisQueueService.createWorker(
'webhooks',
async (job: BullJob<{ dbJobId?: string }>) => {
const dbJobId = job.data?.dbJobId;
if (!dbJobId) {
throw new Error('Redis webhook job missing dbJobId');
}
const claimed = await this.jobsService.markProcessing(dbJobId);
if (!claimed) {
return;
}
await this.webhooksService.processJob(dbJobId);
},
);
}
async onModuleDestroy() {
if (this.worker) {
await this.worker.close();
this.worker = null;
}
}
}

View File

@ -0,0 +1,76 @@
import {
Body,
Controller,
Get,
Headers,
HttpCode,
Param,
Post as HttpPost,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { WebhooksService } from './webhooks.service';
import { AuthGuard } from '../common/auth.guard';
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@Get('whatsapp')
verify(
@Query('hub.mode') mode?: string,
@Query('hub.verify_token') token?: string,
@Query('hub.challenge') challenge?: string,
) {
return this.webhooksService.verifyChallenge(mode, token, challenge);
}
@HttpCode(200)
@Post('whatsapp')
receiveDefault(
@Body() body: unknown,
@Req() request: Request & { rawBody?: Buffer },
@Headers() headers: Record<string, string | string[] | undefined>,
) {
return this.webhooksService.receive('default', body, headers, request.rawBody);
}
@HttpCode(200)
@Post('whatsapp/:provider')
receiveByProvider(
@Param('provider') provider: string,
@Body() body: unknown,
@Req() request: Request & { rawBody?: Buffer },
@Headers() headers: Record<string, string | string[] | undefined>,
) {
return this.webhooksService.receive(provider, body, headers, request.rawBody);
}
@UseGuards(AuthGuard)
@Get('logs')
logs(
@Query('limit') limit?: string,
@Query('provider') provider?: string,
@Query('status') status?: string,
) {
const parsedLimit = limit ? Number(limit) : 50;
return this.webhooksService.findAll(
Number.isFinite(parsedLimit) ? parsedLimit : 50,
provider,
status,
);
}
@UseGuards(AuthGuard)
@HttpPost('logs/:eventId/retry')
retry(
@Req() request: Request & { user: AuthenticatedUser },
@Param('eventId') eventId: string,
) {
return this.webhooksService.retryEvent(eventId, request.user, request.ip);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ConversationsModule } from '../conversations/conversations.module';
import { JobsModule } from '../jobs/jobs.module';
import { PrismaModule } from '../prisma/prisma.module';
import { WebhooksController } from './webhooks.controller';
import { WebhookWorkerService } from './webhook-worker.service';
import { WebhooksService } from './webhooks.service';
@Module({
imports: [PrismaModule, AuthModule, JobsModule, ConversationsModule],
controllers: [WebhooksController],
providers: [WebhooksService, WebhookWorkerService],
exports: [WebhooksService],
})
export class WebhooksModule {}

View File

@ -0,0 +1,392 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { getAppConfig } from '../config/env';
import { AuthenticatedUser } from '../auth/auth.types';
import { ConversationsService } from '../conversations/conversations.service';
import { JobsService } from '../jobs/jobs.service';
import { defaultWhatsappSubscriptions } from '../integrations/whatsapp-subscriptions';
import { normalizePhoneNumber } from '../common/normalize';
import { PrismaService } from '../prisma/prisma.service';
import {
normalizeWebhookPayload,
verifyMetaSignature,
} from './webhooks.utils';
import type {
NormalizedWebhookEvent,
WebhookHeaders,
WebhookReceiveSummary,
} from './webhooks.types';
@Injectable()
export class WebhooksService {
constructor(
private readonly prisma: PrismaService,
private readonly jobsService: JobsService,
private readonly conversationsService: ConversationsService,
) {}
async verifyChallenge(mode?: string, token?: string, challenge?: string) {
const config = await this.getEffectiveWhatsappConfig();
if (mode !== 'subscribe') {
throw new BadRequestException('Unsupported webhook verification mode');
}
if ((token || '') !== config.webhookVerifyToken) {
throw new UnauthorizedException('Invalid webhook verify token');
}
return challenge || '';
}
async receive(
provider: string,
payload: unknown,
headers: WebhookHeaders,
rawBody?: Buffer,
): Promise<WebhookReceiveSummary> {
const verification = await this.verifyWebhookRequest(provider, rawBody, headers);
const normalizedEvents = normalizeWebhookPayload(provider, payload);
let queued = 0;
let duplicates = 0;
let ignored = 0;
const queuedEventIds: string[] = [];
const config = await this.getEffectiveWhatsappConfig();
for (const event of normalizedEvents) {
const outcome = await this.persistNormalizedEvent(
event,
verification.verified,
config.subscriptions.includes(event.eventType),
);
if (outcome === 'queued') {
queued += 1;
queuedEventIds.push(event.eventId);
} else if (outcome === 'duplicate') {
duplicates += 1;
} else {
ignored += 1;
}
}
return {
provider: provider.toLowerCase(),
verified: verification.verified,
verification: verification.reason,
received: normalizedEvents.length,
queued,
duplicates,
ignored,
eventIds: normalizedEvents.map((event) => event.eventId),
};
}
findAll(limit = 50, provider?: string, status?: string) {
return this.prisma.webhookEvent.findMany({
where: {
provider: provider?.trim() || undefined,
processingStatus: status?.trim() || undefined,
},
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(limit, 1), 100),
});
}
async retryEvent(eventId: string, user?: AuthenticatedUser, ipAddress?: string) {
const event = await this.prisma.webhookEvent.findUnique({
where: { eventId },
});
if (!event) {
throw new NotFoundException('Webhook event not found');
}
await this.prisma.$transaction(async (tx) => {
await tx.webhookEvent.update({
where: { eventId },
data: {
processingStatus: 'queued',
processingNotes: 'Manually requeued from retry endpoint',
},
});
});
await this.jobsService.enqueue({
queueName: 'webhooks',
jobType: 'webhook.process',
payload: { eventId },
maxAttempts: 3,
});
if (user) {
const actor = await this.prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, name: true, email: true },
});
await this.prisma.auditLog.create({
data: {
actorUserId: actor?.id || user.sub,
actorName: actor?.name || user.email,
actorEmail: actor?.email || user.email,
actionType: 'Webhook Retry Queued',
module: 'Webhooks',
ipAddress: ipAddress || null,
severity: 'default',
details: `Manually requeued webhook event ${eventId}.`,
},
});
}
return {
eventId,
status: 'queued',
};
}
async processJob(jobId: string) {
const job = await this.jobsService.findById(jobId);
if (!job) {
return;
}
try {
const payload = job.payloadJson as { eventId?: string };
const eventId = payload?.eventId;
if (!eventId) {
throw new Error('Webhook job payload is missing eventId');
}
await this.processSingleEvent(eventId);
await this.jobsService.complete(jobId);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown processing error';
if (job.attempts < job.maxAttempts) {
await this.jobsService.retry(jobId, message, 2000 * job.attempts);
} else {
await this.jobsService.fail(jobId, message);
}
}
}
private async persistNormalizedEvent(
event: NormalizedWebhookEvent,
verified: boolean,
isSubscribed: boolean,
) {
try {
await this.prisma.$transaction(async (tx) => {
await tx.webhookEvent.create({
data: {
provider: event.provider,
eventId: event.eventId,
eventType: event.eventType,
senderPhone: event.senderPhone,
recipientPhone: event.recipientPhone,
externalMessageId: event.externalMessageId,
eventTimestamp: event.eventTimestamp,
payloadJson: event.payload as Prisma.InputJsonValue,
verified,
processingStatus: isSubscribed ? 'queued' : 'ignored',
processingNotes: isSubscribed
? 'Queued for webhook worker'
: 'Ignored because event subscription is disabled',
},
});
});
if (!isSubscribed) {
return 'ignored' as const;
}
await this.jobsService.enqueue({
queueName: 'webhooks',
jobType: 'webhook.process',
payload: { eventId: event.eventId },
maxAttempts: 3,
});
return 'queued' as const;
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return 'duplicate' as const;
}
throw error;
}
}
private async verifyWebhookRequest(
provider: string,
rawBody: Buffer | undefined,
headers: WebhookHeaders,
) {
const config = await this.getEffectiveWhatsappConfig();
const normalizedProvider = provider.toLowerCase();
const metaSignature = this.readHeader(headers['x-hub-signature-256']);
const genericSecret = this.readHeader(headers['x-webhook-secret']);
if (normalizedProvider === 'meta' && config.appSecret) {
if (!rawBody || !metaSignature) {
throw new UnauthorizedException('Missing meta webhook signature');
}
verifyMetaSignature(rawBody, metaSignature, config.appSecret);
return { verified: true, reason: 'meta-signature' };
}
if (genericSecret) {
if (genericSecret !== config.sharedSecret) {
throw new UnauthorizedException('Invalid webhook shared secret');
}
return { verified: true, reason: 'shared-secret' };
}
if (config.allowUnsigned) {
return { verified: false, reason: 'unsigned-development-request' };
}
throw new UnauthorizedException('Webhook request could not be verified');
}
private async getEffectiveWhatsappConfig() {
const env = getAppConfig();
const stored = await this.prisma.integrationConfig.findUnique({
where: { configKey: 'whatsapp' },
});
const storedJson = (stored?.configJson as Record<string, unknown> | null) ?? {};
return {
webhookVerifyToken:
typeof storedJson.webhookVerifyToken === 'string'
? storedJson.webhookVerifyToken
: env.webhookVerifyToken,
sharedSecret:
typeof storedJson.sharedSecret === 'string'
? storedJson.sharedSecret
: env.webhookSharedSecret,
appSecret:
typeof storedJson.appSecret === 'string'
? storedJson.appSecret
: env.metaWebhookAppSecret,
allowUnsigned: env.webhookAllowUnsigned,
subscriptions:
Array.isArray(storedJson.subscriptions) && storedJson.subscriptions.length > 0
? storedJson.subscriptions.filter((item): item is string => typeof item === 'string')
: defaultWhatsappSubscriptions,
};
}
private readHeader(value: string | string[] | undefined) {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
private async processSingleEvent(eventId: string) {
const event = await this.prisma.webhookEvent.findUnique({
where: { eventId },
});
if (!event) {
throw new Error(`Webhook event ${eventId} not found`);
}
if (event.processingStatus === 'processed') {
return;
}
if (event.eventType === 'message.inbound' && event.senderPhone) {
const normalizedPhone = normalizePhoneNumber(event.senderPhone);
const contact = await this.prisma.contact.upsert({
where: { phoneNumber: normalizedPhone },
update: {},
create: {
name: normalizedPhone,
phoneNumber: normalizedPhone,
notes: `Created from inbound webhook event ${event.eventId}`,
},
});
await this.conversationsService.syncInboundFromWebhookEvent({
webhookEventId: event.eventId,
contactId: contact.id,
externalMessageId: event.externalMessageId,
body: this.extractMessageBody(event.payloadJson),
occurredAt: event.eventTimestamp,
});
}
if (
(event.eventType === 'message.sent' || event.eventType === 'message.delivered' || event.eventType === 'message.read' || event.eventType === 'message.failed')
&& event.externalMessageId
) {
await this.prisma.conversationMessage.updateMany({
where: {
externalMessageId: event.externalMessageId,
},
data: {
status:
event.eventType === 'message.read'
? 'read'
: event.eventType === 'message.delivered'
? 'delivered'
: event.eventType === 'message.failed'
? 'failed'
: 'sent',
...(event.eventType === 'message.read' ? { readAt: event.eventTimestamp } : {}),
},
});
}
await this.prisma.webhookEvent.update({
where: { eventId },
data: {
processingStatus: 'processed',
processingNotes:
event.eventType === 'message.inbound' && event.senderPhone
? 'Processed and synced inbound sender into contacts'
: 'Processed by webhook worker',
},
});
}
private extractMessageBody(payload: unknown) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return 'Inbound message received.';
}
const record = payload as Record<string, unknown>;
const textRecord =
record.text && typeof record.text === 'object' && !Array.isArray(record.text)
? (record.text as Record<string, unknown>)
: null;
const interactiveRecord =
record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive)
? (record.interactive as Record<string, unknown>)
: null;
return (
[
typeof textRecord?.body === 'string' ? textRecord.body : null,
typeof record.body === 'string' ? record.body : null,
typeof record.caption === 'string' ? record.caption : null,
typeof interactiveRecord?.title === 'string' ? interactiveRecord.title : null,
typeof record.type === 'string' ? `[${record.type}]` : null,
].find((value) => typeof value === 'string' && value.trim()) || 'Inbound message received.'
);
}
}

View File

@ -0,0 +1,28 @@
export type WebhookHeaders = Record<string, string | string[] | undefined>;
export type WebhookVerificationResult = {
verified: boolean;
reason: string;
};
export type NormalizedWebhookEvent = {
provider: string;
eventType: string;
eventId: string;
senderPhone?: string;
recipientPhone?: string;
externalMessageId?: string;
eventTimestamp: Date;
payload: Record<string, unknown>;
};
export type WebhookReceiveSummary = {
provider: string;
verified: boolean;
verification: string;
received: number;
queued: number;
duplicates: number;
ignored: number;
eventIds: string[];
};

View File

@ -0,0 +1,216 @@
import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import type {
NormalizedWebhookEvent,
} from './webhooks.types';
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function readTimestamp(value: unknown) {
if (typeof value === 'number' || (typeof value === 'string' && value.trim())) {
const raw = String(value).trim();
const milliseconds = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw);
if (!Number.isNaN(milliseconds)) {
return new Date(milliseconds);
}
}
return new Date();
}
function buildEventId(provider: string, payload: Record<string, unknown>, seed?: string) {
const hash = createHash('sha256')
.update(provider)
.update(':')
.update(seed || JSON.stringify(payload))
.digest('hex');
return `evt_${hash.slice(0, 32)}`;
}
function buildMetaEvents(payload: Record<string, unknown>, provider: string) {
const normalized: NormalizedWebhookEvent[] = [];
const entries = asArray(payload.entry);
for (const entry of entries) {
const entryRecord = asRecord(entry);
const changes = asArray(entryRecord?.changes);
for (const change of changes) {
const changeRecord = asRecord(change);
const field = readString(changeRecord?.field);
const value = asRecord(changeRecord?.value);
if (!value) {
continue;
}
const metadata = asRecord(value.metadata);
const recipientPhone = readString(metadata?.display_phone_number) || readString(metadata?.phone_number_id);
for (const status of asArray(value.statuses)) {
const statusRecord = asRecord(status);
if (!statusRecord) {
continue;
}
const rawStatus = readString(statusRecord.status) || 'sent';
const statusMap: Record<string, string> = {
sent: 'message.sent',
delivered: 'message.delivered',
read: 'message.read',
failed: 'message.failed',
};
normalized.push({
provider,
eventType: statusMap[rawStatus] || 'message.sent',
eventId:
readString(statusRecord.id) ||
readString(statusRecord.meta_msg_id) ||
buildEventId(provider, statusRecord, rawStatus),
senderPhone: readString(statusRecord.recipient_id),
recipientPhone,
externalMessageId: readString(statusRecord.id),
eventTimestamp: readTimestamp(statusRecord.timestamp),
payload: statusRecord,
});
}
for (const message of asArray(value.messages)) {
const messageRecord = asRecord(message);
if (!messageRecord) {
continue;
}
const contacts = asArray(value.contacts);
const firstContact = asRecord(contacts[0]);
const senderPhone =
readString(messageRecord.from) ||
readString(firstContact?.wa_id) ||
readString(firstContact?.phone);
normalized.push({
provider,
eventType: field === 'messages' ? 'message.inbound' : 'account.updated',
eventId:
readString(messageRecord.id) ||
buildEventId(provider, messageRecord, readString(messageRecord.from)),
senderPhone,
recipientPhone,
externalMessageId: readString(messageRecord.id),
eventTimestamp: readTimestamp(messageRecord.timestamp),
payload: messageRecord,
});
}
if (field === 'message_template_status_update') {
normalized.push({
provider,
eventType: 'template.updated',
eventId: buildEventId(provider, value, 'template'),
recipientPhone,
eventTimestamp: new Date(),
payload: value,
});
}
}
}
return normalized;
}
function buildGenericEvents(payload: Record<string, unknown>, provider: string) {
const eventType =
readString(payload.event_type) ||
readString(payload.eventType) ||
readString(payload.type) ||
'account.updated';
const senderPhone =
readString(payload.sender_phone) ||
readString(payload.senderPhone) ||
readString(payload.from);
const recipientPhone =
readString(payload.recipient_phone) ||
readString(payload.recipientPhone) ||
readString(payload.to);
const externalMessageId =
readString(payload.external_message_id) ||
readString(payload.externalMessageId) ||
readString(payload.message_id) ||
readString(payload.messageId);
const seed =
readString(payload.event_id) ||
readString(payload.id) ||
externalMessageId ||
JSON.stringify(payload);
return [
{
provider,
eventType,
eventId:
readString(payload.event_id) ||
readString(payload.eventId) ||
readString(payload.id) ||
buildEventId(provider, payload, seed),
senderPhone,
recipientPhone,
externalMessageId,
eventTimestamp: readTimestamp(payload.timestamp || payload.created_at || payload.createdAt),
payload,
},
];
}
export function verifyMetaSignature(rawBody: Buffer, signatureHeader: string, appSecret: string) {
const [scheme, receivedSignature] = signatureHeader.split('=');
if (scheme !== 'sha256' || !receivedSignature) {
throw new UnauthorizedException('Invalid meta webhook signature format');
}
const expectedSignature = createHmac('sha256', appSecret).update(rawBody).digest('hex');
const receivedBuffer = Buffer.from(receivedSignature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (
receivedBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(receivedBuffer, expectedBuffer)
) {
throw new UnauthorizedException('Invalid meta webhook signature');
}
}
export function normalizeWebhookPayload(provider: string, payload: unknown) {
const payloadRecord = asRecord(payload);
if (!payloadRecord) {
throw new BadRequestException('Webhook payload must be a JSON object');
}
const normalizedProvider = provider.toLowerCase();
if (
normalizedProvider === 'meta' &&
readString(payloadRecord.object) === 'whatsapp_business_account'
) {
const metaEvents = buildMetaEvents(payloadRecord, normalizedProvider);
if (metaEvents.length > 0) {
return metaEvents;
}
}
return buildGenericEvents(payloadRecord, normalizedProvider);
}