Initial BizOne portal setup
This commit is contained in:
37
backend/src/app.module.ts
Normal file
37
backend/src/app.module.ts
Normal 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 {}
|
||||
130
backend/src/auth/auth.controller.ts
Normal file
130
backend/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
backend/src/auth/auth.module.ts
Normal file
22
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||
1088
backend/src/auth/auth.service.ts
Normal file
1088
backend/src/auth/auth.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
7
backend/src/auth/auth.types.ts
Normal file
7
backend/src/auth/auth.types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type AuthenticatedUser = {
|
||||
sub: string;
|
||||
email: string;
|
||||
ver: number;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
};
|
||||
7
backend/src/auth/dto/complete-password-reset.dto.ts
Normal file
7
backend/src/auth/dto/complete-password-reset.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CompletePasswordResetDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
13
backend/src/auth/dto/login.dto.ts
Normal file
13
backend/src/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
10
backend/src/auth/dto/logout.dto.ts
Normal file
10
backend/src/auth/dto/logout.dto.ts
Normal 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;
|
||||
}
|
||||
9
backend/src/auth/dto/refresh-token.dto.ts
Normal file
9
backend/src/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||
}
|
||||
6
backend/src/auth/dto/request-password-reset.dto.ts
Normal file
6
backend/src/auth/dto/request-password-reset.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class RequestPasswordResetDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
7
backend/src/auth/dto/two-factor-code.dto.ts
Normal file
7
backend/src/auth/dto/two-factor-code.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class TwoFactorCodeDto {
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/)
|
||||
code!: string;
|
||||
}
|
||||
10
backend/src/auth/dto/verify-two-factor-login.dto.ts
Normal file
10
backend/src/auth/dto/verify-two-factor-login.dto.ts
Normal 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
131
backend/src/auth/totp.ts
Normal 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');
|
||||
}
|
||||
42
backend/src/campaigns/campaign-worker.service.ts
Normal file
42
backend/src/campaigns/campaign-worker.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
backend/src/campaigns/campaigns.controller.ts
Normal file
103
backend/src/campaigns/campaigns.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
backend/src/campaigns/campaigns.module.ts
Normal file
16
backend/src/campaigns/campaigns.module.ts
Normal 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 {}
|
||||
966
backend/src/campaigns/campaigns.service.ts
Normal file
966
backend/src/campaigns/campaigns.service.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
99
backend/src/campaigns/dto/create-campaign.dto.ts
Normal file
99
backend/src/campaigns/dto/create-campaign.dto.ts
Normal 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;
|
||||
}
|
||||
22
backend/src/campaigns/dto/send-campaign.dto.ts
Normal file
22
backend/src/campaigns/dto/send-campaign.dto.ts
Normal 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;
|
||||
}
|
||||
106
backend/src/campaigns/dto/update-campaign.dto.ts
Normal file
106
backend/src/campaigns/dto/update-campaign.dto.ts
Normal 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;
|
||||
}
|
||||
32
backend/src/common/auth.guard.ts
Normal file
32
backend/src/common/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
backend/src/common/normalize.ts
Normal file
25
backend/src/common/normalize.ts
Normal 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();
|
||||
}
|
||||
13
backend/src/common/password.ts
Normal file
13
backend/src/common/password.ts
Normal 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;
|
||||
}
|
||||
9
backend/src/common/permission.decorator.ts
Normal file
9
backend/src/common/permission.decorator.ts
Normal 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 });
|
||||
}
|
||||
124
backend/src/common/permission.guard.ts
Normal file
124
backend/src/common/permission.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
backend/src/common/prisma-exception.filter.ts
Normal file
36
backend/src/common/prisma-exception.filter.ts
Normal 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
270
backend/src/config/env.ts
Normal 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;
|
||||
}
|
||||
86
backend/src/contacts/contacts.controller.ts
Normal file
86
backend/src/contacts/contacts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/src/contacts/contacts.module.ts
Normal file
11
backend/src/contacts/contacts.module.ts
Normal 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 {}
|
||||
413
backend/src/contacts/contacts.service.ts
Normal file
413
backend/src/contacts/contacts.service.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
39
backend/src/conversations/conversations.controller.ts
Normal file
39
backend/src/conversations/conversations.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/conversations/conversations.module.ts
Normal file
13
backend/src/conversations/conversations.module.ts
Normal 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 {}
|
||||
487
backend/src/conversations/conversations.service.ts
Normal file
487
backend/src/conversations/conversations.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
backend/src/conversations/dto/send-message.dto.ts
Normal file
8
backend/src/conversations/dto/send-message.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SendConversationMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
body!: string;
|
||||
}
|
||||
19
backend/src/dashboard/dashboard.controller.ts
Normal file
19
backend/src/dashboard/dashboard.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
backend/src/dashboard/dashboard.module.ts
Normal file
11
backend/src/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
182
backend/src/dashboard/dashboard.service.ts
Normal file
182
backend/src/dashboard/dashboard.service.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
50
backend/src/dto/create-contact.dto.ts
Normal file
50
backend/src/dto/create-contact.dto.ts
Normal 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;
|
||||
}
|
||||
81
backend/src/dto/update-contact.dto.ts
Normal file
81
backend/src/dto/update-contact.dto.ts
Normal 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;
|
||||
}
|
||||
23
backend/src/health/health.controller.ts
Normal file
23
backend/src/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/health/health.module.ts
Normal file
9
backend/src/health/health.module.ts
Normal 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 {}
|
||||
14
backend/src/integrations/dto/test-whatsapp-config.dto.ts
Normal file
14
backend/src/integrations/dto/test-whatsapp-config.dto.ts
Normal 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;
|
||||
}
|
||||
63
backend/src/integrations/dto/update-whatsapp-config.dto.ts
Normal file
63
backend/src/integrations/dto/update-whatsapp-config.dto.ts
Normal 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[];
|
||||
}
|
||||
34
backend/src/integrations/integrations.controller.ts
Normal file
34
backend/src/integrations/integrations.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/src/integrations/integrations.module.ts
Normal file
11
backend/src/integrations/integrations.module.ts
Normal 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 {}
|
||||
171
backend/src/integrations/integrations.service.ts
Normal file
171
backend/src/integrations/integrations.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
41
backend/src/integrations/whatsapp-subscriptions.ts
Normal file
41
backend/src/integrations/whatsapp-subscriptions.ts
Normal 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'];
|
||||
10
backend/src/jobs/jobs.module.ts
Normal file
10
backend/src/jobs/jobs.module.ts
Normal 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 {}
|
||||
129
backend/src/jobs/jobs.service.ts
Normal file
129
backend/src/jobs/jobs.service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
69
backend/src/jobs/redis-queue.service.ts
Normal file
69
backend/src/jobs/redis-queue.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
backend/src/logs/dto/create-audit-log.dto.ts
Normal file
20
backend/src/logs/dto/create-audit-log.dto.ts
Normal 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';
|
||||
}
|
||||
95
backend/src/logs/logs.controller.ts
Normal file
95
backend/src/logs/logs.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/logs/logs.module.ts
Normal file
12
backend/src/logs/logs.module.ts
Normal 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 {}
|
||||
181
backend/src/logs/logs.service.ts
Normal file
181
backend/src/logs/logs.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
8
backend/src/mailer/mailer.module.ts
Normal file
8
backend/src/mailer/mailer.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
@Module({
|
||||
providers: [MailerService],
|
||||
exports: [MailerService],
|
||||
})
|
||||
export class MailerModule {}
|
||||
172
backend/src/mailer/mailer.service.ts
Normal file
172
backend/src/mailer/mailer.service.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
36
backend/src/main.ts
Normal file
36
backend/src/main.ts
Normal 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();
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
25
backend/src/prisma/prisma.service.ts
Normal file
25
backend/src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
33
backend/src/roles/dto/create-role.dto.ts
Normal file
33
backend/src/roles/dto/create-role.dto.ts
Normal 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>;
|
||||
}>;
|
||||
}
|
||||
35
backend/src/roles/dto/update-role.dto.ts
Normal file
35
backend/src/roles/dto/update-role.dto.ts
Normal 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>;
|
||||
}>;
|
||||
}
|
||||
40
backend/src/roles/roles.controller.ts
Normal file
40
backend/src/roles/roles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/roles/roles.module.ts
Normal file
12
backend/src/roles/roles.module.ts
Normal 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 {}
|
||||
242
backend/src/roles/roles.service.ts
Normal file
242
backend/src/roles/roles.service.ts
Normal 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()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
47
backend/src/templates/dto/create-template.dto.ts
Normal file
47
backend/src/templates/dto/create-template.dto.ts
Normal 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 }>;
|
||||
}
|
||||
52
backend/src/templates/dto/update-template.dto.ts
Normal file
52
backend/src/templates/dto/update-template.dto.ts
Normal 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 }>;
|
||||
}
|
||||
51
backend/src/templates/templates.controller.ts
Normal file
51
backend/src/templates/templates.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/templates/templates.module.ts
Normal file
13
backend/src/templates/templates.module.ts
Normal 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 {}
|
||||
341
backend/src/templates/templates.service.ts
Normal file
341
backend/src/templates/templates.service.ts
Normal 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
1
backend/src/types/bcryptjs.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'bcryptjs';
|
||||
8
backend/src/users/dto/complete-invitation.dto.ts
Normal file
8
backend/src/users/dto/complete-invitation.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CompleteInvitationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
14
backend/src/users/dto/create-user.dto.ts
Normal file
14
backend/src/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
||||
20
backend/src/users/dto/update-user.dto.ts
Normal file
20
backend/src/users/dto/update-user.dto.ts
Normal 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';
|
||||
}
|
||||
84
backend/src/users/users.controller.ts
Normal file
84
backend/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend/src/users/users.module.ts
Normal file
14
backend/src/users/users.module.ts
Normal 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 {}
|
||||
380
backend/src/users/users.service.ts
Normal file
380
backend/src/users/users.service.ts
Normal 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 } : {};
|
||||
}
|
||||
}
|
||||
42
backend/src/webhooks/webhook-worker.service.ts
Normal file
42
backend/src/webhooks/webhook-worker.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
backend/src/webhooks/webhooks.controller.ts
Normal file
76
backend/src/webhooks/webhooks.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
backend/src/webhooks/webhooks.module.ts
Normal file
16
backend/src/webhooks/webhooks.module.ts
Normal 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 {}
|
||||
392
backend/src/webhooks/webhooks.service.ts
Normal file
392
backend/src/webhooks/webhooks.service.ts
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
28
backend/src/webhooks/webhooks.types.ts
Normal file
28
backend/src/webhooks/webhooks.types.ts
Normal 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[];
|
||||
};
|
||||
216
backend/src/webhooks/webhooks.utils.ts
Normal file
216
backend/src/webhooks/webhooks.utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user