add new sms-queue
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-01 20:26:00 +03:30
parent 91b67e0377
commit d2838f0976
9 changed files with 197 additions and 31 deletions
@@ -0,0 +1,40 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260701170000_updateSmsLogsEntity extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "sms_logs" add column "count" int not null default 0;`);
this.addSql(`
with aggregated as (
select restaurant_id, count(*)::int as total_count
from sms_logs
group by restaurant_id
)
update sms_logs sl
set count = a.total_count
from aggregated a
where sl.restaurant_id = a.restaurant_id
and sl.id = (
select min(id) from sms_logs where restaurant_id = sl.restaurant_id
);
`);
this.addSql(`
delete from sms_logs sl1
using sms_logs sl2
where sl1.restaurant_id = sl2.restaurant_id
and sl1.id > sl2.id;
`);
this.addSql(`alter table "sms_logs" drop column "phone";`);
this.addSql(`alter table "sms_logs" add constraint "sms_logs_restaurant_id_unique" unique ("restaurant_id");`);
}
override async down(): Promise<void> {
this.addSql(`alter table "sms_logs" drop constraint if exists "sms_logs_restaurant_id_unique";`);
this.addSql(`alter table "sms_logs" add column "phone" varchar(255) not null default '';`);
this.addSql(`alter table "sms_logs" drop column "count";`);
}
}
@@ -1,5 +1,6 @@
export enum NotificationQueueNameEnum { export enum NotificationQueueNameEnum {
SMS = 'sms', SMS = 'sms',
DIRECT_SMS = 'direct-sms',
PUSH = 'push', PUSH = 'push',
IN_APP = 'in-app', IN_APP = 'in-app',
} }
@@ -6,12 +6,9 @@ export class SmsLog {
@PrimaryKey() @PrimaryKey()
id!: number; id!: number;
@ManyToOne(() => Restaurant) @ManyToOne(() => Restaurant, { unique: true })
restaurant!: Restaurant; restaurant!: Restaurant;
@Property() @Property({ default: 0 })
phone!: string; count: number = 0;
@Property()
createdAt: Date = new Date();
} }
@@ -6,6 +6,13 @@ export interface SmsNotificationQueueJob {
restaurantId: string; restaurantId: string;
parameters?: Record<string, string>; parameters?: Record<string, string>;
} }
export interface DirectSmsQueueJob {
phone: string;
templateId: string;
params?: Record<string, unknown>;
restaurantId?: string;
}
export interface PushNotificationQueueJob { export interface PushNotificationQueueJob {
title: string; title: string;
content: string; content: string;
@@ -21,10 +21,9 @@ export class SmsListeners {
async handleSmsSent(event: SmsSentEvent) { async handleSmsSent(event: SmsSentEvent) {
try { try {
this.logger.log( this.logger.log(
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.restaurantId}`, `SMS sent event received for restaurant: ${event.restaurantId}`,
); );
// Get the restaurant entity
const restaurant = await this.restRepository.findOne({ id: event.restaurantId }); const restaurant = await this.restRepository.findOne({ id: event.restaurantId });
if (!restaurant) { if (!restaurant) {
@@ -34,17 +33,22 @@ export class SmsListeners {
return; return;
} }
// Create SMS log record let smsLog = await this.em.findOne(SmsLog, { restaurant: event.restaurantId });
const smsLog = this.em.create(SmsLog, {
restaurant: restaurant, if (smsLog) {
phone: event.phoneNumber, smsLog.count += 1;
createdAt: new Date(), } else {
}); smsLog = this.em.create(SmsLog, {
restaurant,
count: 1,
createdAt: new Date(),
});
}
await this.em.flush(); await this.em.flush();
this.logger.log( this.logger.log(
`SMS log created successfully: ${smsLog.id} for phone ${event.phoneNumber}`, `SMS log updated successfully: ${smsLog.id} for restaurant ${event.restaurantId}, count: ${smsLog.count}`,
); );
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
@@ -9,6 +9,7 @@ import { NotificationPreferenceService } from './services/notification-preferenc
import { NotificationQueueService } from './services/notification-queue.service'; import { NotificationQueueService } from './services/notification-queue.service';
import { PushNotificationService } from './services/push-notification.service'; import { PushNotificationService } from './services/push-notification.service';
import { SmsProcessor } from './processors/sms.processor'; import { SmsProcessor } from './processors/sms.processor';
import { DirectSmsProcessor } from './processors/direct-sms.processor';
import { PushProcessor } from './processors/push.processor'; import { PushProcessor } from './processors/push.processor';
import { NotificationsController } from './controllers/notifications.controller'; import { NotificationsController } from './controllers/notifications.controller';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
@@ -24,6 +25,7 @@ import { AdminModule } from '../admin/admin.module';
import { UserModule } from '../users/user.module'; import { UserModule } from '../users/user.module';
import { NotificationCrone } from './crone/notification.crone'; import { NotificationCrone } from './crone/notification.crone';
import { SmsService } from './services/sms.service'; import { SmsService } from './services/sms.service';
import { SmsQueueService } from './services/sms-queue.service';
import { SmsLog } from './entities/smsLogs.entity'; import { SmsLog } from './entities/smsLogs.entity';
import { SmsLogRepository } from './repositories/sms-log.repository'; import { SmsLogRepository } from './repositories/sms-log.repository';
import { RestaurantsModule } from '../restaurants/restaurants.module'; import { RestaurantsModule } from '../restaurants/restaurants.module';
@@ -36,6 +38,7 @@ import { SmsListeners } from './listeners/sms.listeners';
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant, SmsLog]), MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant, SmsLog]),
BullModule.registerQueue( BullModule.registerQueue(
{ name: NotificationQueueNameEnum.SMS }, { name: NotificationQueueNameEnum.SMS },
{ name: NotificationQueueNameEnum.DIRECT_SMS },
{ name: NotificationQueueNameEnum.PUSH }, { name: NotificationQueueNameEnum.PUSH },
{ name: NotificationQueueNameEnum.IN_APP }, { name: NotificationQueueNameEnum.IN_APP },
), ),
@@ -62,15 +65,17 @@ import { SmsListeners } from './listeners/sms.listeners';
PushNotificationService, PushNotificationService,
PushProcessor, PushProcessor,
SmsProcessor, SmsProcessor,
DirectSmsProcessor,
InAppProcessor, InAppProcessor,
NotificationsGateway, NotificationsGateway,
WsAdminAuthGuard, WsAdminAuthGuard,
NotificationCrone, NotificationCrone,
SmsService, SmsService,
SmsQueueService,
SmsLogRepository, SmsLogRepository,
SmsListeners, SmsListeners,
], ],
exports: [NotificationService, NotificationPreferenceService, exports: [NotificationService, NotificationPreferenceService,
NotificationQueueService, PushNotificationService, SmsService], NotificationQueueService, PushNotificationService, SmsService, SmsQueueService],
}) })
export class NotificationsModule { } export class NotificationsModule { }
@@ -0,0 +1,55 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DirectSmsQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { SmsService } from '../services/sms.service';
import { SmsSentEvent } from '../events/sms.events';
@Processor(NotificationQueueNameEnum.DIRECT_SMS)
export class DirectSmsProcessor extends WorkerHost {
private readonly logger = new Logger(DirectSmsProcessor.name);
constructor(
private readonly smsService: SmsService,
private readonly eventEmitter: EventEmitter2,
) {
super();
}
async process(job: Job<DirectSmsQueueJob>) {
const { phone, templateId, params, restaurantId } = job.data;
this.logger.log(`Processing direct SMS - phone: ${phone}, templateId: ${templateId}`);
try {
await this.smsService.sendSms({
phone,
templateId,
parameters: params as Record<string, string | number | Date>,
});
this.logger.log(`Direct SMS sent successfully to ${phone}`);
if (restaurantId) {
this.eventEmitter.emit(SmsSentEvent.name, new SmsSentEvent(phone, restaurantId));
}
return { success: true };
} catch (error) {
this.logger.error(`Error processing direct SMS job ${job.id}:`, error);
throw error;
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Direct SMS job ${job.id} completed`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, error: Error) {
this.logger.error(`Direct SMS job ${job.id} failed:`, error);
}
}
@@ -15,26 +15,23 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> { ): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Get total count of unique restaurants with SMS logs
const totalResult = await this.em.execute( const totalResult = await this.em.execute(
` `
SELECT COUNT(DISTINCT sl.restaurant_id) as total SELECT COUNT(*) as total
FROM sms_logs sl FROM sms_logs sl
WHERE sl.restaurant_id IS NOT NULL WHERE sl.restaurant_id IS NOT NULL
`, `,
); );
const total = parseInt(totalResult[0]?.total || '0', 10); const total = parseInt(totalResult[0]?.total || '0', 10);
// Get paginated results with SMS counts grouped by restaurant
const results = await this.em.execute( const results = await this.em.execute(
` `
SELECT SELECT
r.id as "restaurantId", r.id as "restaurantId",
r.name as "restaurantName", r.name as "restaurantName",
COUNT(sl.id)::int as "smsCount" sl.count as "smsCount"
FROM sms_logs sl FROM sms_logs sl
INNER JOIN restaurants r ON sl.restaurant_id = r.id INNER JOIN restaurants r ON sl.restaurant_id = r.id
GROUP BY r.id, r.name
ORDER BY "smsCount" DESC, r.name ASC ORDER BY "smsCount" DESC, r.name ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, `,
@@ -61,16 +58,8 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
} }
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> { async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
const result = await this.em.execute( const smsLog = await this.findOne({ restaurant: restaurantId });
` return smsLog?.count ?? 0;
SELECT COUNT(sl.id)::int as "smsCount"
FROM sms_logs sl
WHERE sl.restaurant_id = ?
`,
[restaurantId],
);
return parseInt(result[0]?.smsCount || '0', 10);
} }
} }
@@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { NotificationQueueNameEnum } from '../constants/queue';
import { DirectSmsQueueJob } from '../interfaces/jobs-queue.interface';
@Injectable()
export class SmsQueueService {
private readonly logger = new Logger(SmsQueueService.name);
constructor(
@InjectQueue(NotificationQueueNameEnum.DIRECT_SMS) private readonly directSmsQueue: Queue,
) {}
async enqueue(job: DirectSmsQueueJob): Promise<void> {
try {
const jobId = `${job.templateId}-${this.generateRandomInt()}-${Date.now()}`;
await this.directSmsQueue.add('direct-sms', job, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
age: 24 * 3600,
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600,
},
});
this.logger.log(`Direct SMS job added to queue: ${jobId}`);
} catch (error) {
this.logger.error('Failed to add direct SMS to queue:', error);
throw error;
}
}
async enqueueBulk(jobs: DirectSmsQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'direct-sms',
data: job,
opts: {
jobId: `${job.templateId}-${this.generateRandomInt()}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.directSmsQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} direct SMS jobs to queue`);
} catch (error) {
this.logger.error('Failed to add bulk direct SMS to queue:', error);
throw error;
}
}
private generateRandomInt(): string {
return Math.random().toString(36).substring(2, 15);
}
}