diff --git a/package-lock.json b/package-lock.json index 0e56015..6b9802a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "bullmq": "^5.73.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", diff --git a/package.json b/package.json index 8f328bd..3a59a50 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "bullmq": "^5.73.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.20", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", diff --git a/src/commons/interfaces/env.ts b/src/commons/interfaces/env.ts index 0ded920..ab5aafd 100644 --- a/src/commons/interfaces/env.ts +++ b/src/commons/interfaces/env.ts @@ -3,7 +3,8 @@ interface IENV { NODE_ENVIRONMENT: 'local' | 'docker' | 'dev' | 'staging' | 'prod'; APP_PORT: number; APP_KEY: string; - ENABLE_RATE_LIMITING: string + ENABLE_RATE_LIMITING: string; + DONT_SEND_EMAIL: string; // Redis REDIS_HOST: string; diff --git a/src/main.ts b/src/main.ts index 15a610d..2196991 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,12 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new GlobalExceptionsHandler()); - app.useGlobalPipes(new ValidationPipe()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ); app.use(morgan('combined')); app.use(helmet()); diff --git a/src/messenger/dto/create-messenger.dto.ts b/src/messenger/dto/create-messenger.dto.ts index 5f8d301..b463892 100644 --- a/src/messenger/dto/create-messenger.dto.ts +++ b/src/messenger/dto/create-messenger.dto.ts @@ -1,4 +1,11 @@ -import { IsDateString, IsNotEmpty, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { IsFutureTimeString } from 'src/utils/validators/is-future-time-string.validator'; export class CreateMessengerDto { @IsString() @@ -11,5 +18,10 @@ export class CreateMessengerDto { @IsDateString({ strict: true }) @IsNotEmpty() + @IsOptional() send_at: string; + + @IsFutureTimeString() + @IsOptional() + send_after: string; } diff --git a/src/messenger/messenger.service.ts b/src/messenger/messenger.service.ts index 313e9ee..9c2aa67 100644 --- a/src/messenger/messenger.service.ts +++ b/src/messenger/messenger.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - GoneException, Injectable, Logger, NotFoundException, @@ -12,12 +11,12 @@ import { MessageToFuture } from './entities/message.entity'; import { Repository } from 'typeorm'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; -import { - EMAIL_TEMPLATES, - QUEUE_NAME, -} from 'src/constants'; +import { EMAIL_TEMPLATES, QUEUE_NAME } from 'src/constants'; import { EmailService } from 'src/email/email.service'; import { User } from 'src/user/entities/user.entity'; +import { parseToTimestamp } from 'src/utils/time.util'; +import { ConfigService } from '@nestjs/config'; + @Injectable() export class MessengerService { constructor( @@ -27,20 +26,37 @@ export class MessengerService { @InjectQueue(QUEUE_NAME.MESSAGE_TO_FUTURE) private messageToFutureQueue: Queue, private readonly emailService: EmailService, + private readonly configService: ConfigService, ) {} private readonly logger = new Logger(); async create(data: CreateMessengerDto, user_id: string) { - // Validate that date 'send_at' is a future date + // Validate that only one of 'sent_at' and 'send_after' can be provided + const onlyOne = Number(!!data.send_at) ^ Number(!!data.send_after); + + if (!onlyOne) { + throw new BadRequestException( + "only provide value for either 'send_at' or 'send_after', not both.", + ); + } + + // Validate that 'send_at' is a future date const isDateFuture = new Date(data.send_at).getTime() > Date.now(); - if (!isDateFuture) { - throw new BadRequestException('send_at should be a time in the future.'); + if (data.send_at && !isDateFuture) { + throw new BadRequestException('send_at must be a time in the future.'); + } + + let sendAfterTimestamp; + + if (data.send_after) { + sendAfterTimestamp = parseToTimestamp(data.send_after); } const message = this.messageToFutureRepository.create({ ...data, + send_at: data.send_at ?? new Date(sendAfterTimestamp), created_by: user_id, }); @@ -52,7 +68,8 @@ export class MessengerService { ); } - const delay = new Date(data.send_at).getTime() - Date.now(); + const delay = + new Date(data.send_at ?? sendAfterTimestamp).getTime() - Date.now(); // Add message to queue const job = await this.messageToFutureQueue.add( @@ -65,7 +82,7 @@ export class MessengerService { if (!job) { throw new UnprocessableEntityException( - 'failed to add message to queue. please try again', + 'failed to add message. please try again', ); } @@ -108,13 +125,15 @@ export class MessengerService { const emailTemplate = EMAIL_TEMPLATES.MESSAGE_TO_FUTURE; - this.emailService.send({ - recipientEmail, - recipientName, - emailSubject, - emailData, - emailTemplate, - }); + if (!(this.configService.get('DONT_SEND_EMAIL', 'false') === 'true')) { + this.emailService.send({ + recipientEmail, + recipientName, + emailSubject, + emailData, + emailTemplate, + }); + } message.sent = true; this.messageToFutureRepository.save(message); diff --git a/src/utils/time.util.ts b/src/utils/time.util.ts new file mode 100644 index 0000000..484e82f --- /dev/null +++ b/src/utils/time.util.ts @@ -0,0 +1,132 @@ +import * as dayjs from 'dayjs'; + +type TimeUnit = 's' | 'm' | 'h' | 'd' | 'w' | 'y'; + +const UNIT_TO_MS: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + y: 365 * 24 * 60 * 60 * 1000, +}; + +function parseBaseTime(base?: string | number | Date): number { + if (!base) return Date.now(); + + if (typeof base === 'number') return base; + + if (base instanceof Date) return base.getTime(); + + const parsed = new Date(base).getTime(); + if (isNaN(parsed)) { + throw new Error(`Invalid base time: ${base}`); + } + + return parsed; +} + +/** + * Get future time without accounting for leap year + * @param duration Time unit. e.g '1y' + * @param baseTime Base time for adding duration + * @returns Future timestamp in milliseconds + */ +export function getFutureTimeWithoutLeap( + duration: string, + baseTime?: string | number | Date, +): number { + const match = duration.match(/^(\d+)([smhdwy])$/); + + if (!match) { + throw new Error(`Invalid duration format: ${duration}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2] as TimeUnit; + + const baseMs = parseBaseTime(baseTime); + const durationMs = value * UNIT_TO_MS[unit]; + + return baseMs + durationMs; +} + +/** + * Get future time accounting for leap year + * @param input Time unit e.g '1d', '4m', '2y' + * @returns Future timestamp in milliseconds + */ +export function getFutureTimeLeap(input: string): number { + const match = input.match(/^(\d+)([smhdwy])$/); + + if (!match) { + throw new Error(`Invalid time format: ${input}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2] as TimeUnit; + + const now = new Date(); + + switch (unit) { + case 's': + now.setSeconds(now.getSeconds() + value); + break; + case 'm': + now.setMinutes(now.getMinutes() + value); + break; + case 'h': + now.setHours(now.getHours() + value); + break; + case 'd': + now.setDate(now.getDate() + value); + break; + case 'w': + now.setDate(now.getDate() + value * 7); + break; + case 'y': + now.setFullYear(now.getFullYear() + value); + break; + } + + return now.getTime(); +} + +export function getFutureTime(input: string): number { + const match = input.match(/^(\d+)([smhdwMy])$/); + + if (!match) { + throw new Error(`Invalid time format: ${input}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + const unitMap: Record = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year', + }; + + return dayjs().add(value, unitMap[unit]).valueOf(); +} + +export function parseToTimestamp(value: string): number { + const [, amountStr, unit] = value.match(/^(\d+)([smhdwMy])$/)!; + + const unitMap: Record = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year', + }; + + return dayjs().add(parseInt(amountStr, 10), unitMap[unit]).valueOf(); +} diff --git a/src/utils/validators/is-future-time-string.validator.ts b/src/utils/validators/is-future-time-string.validator.ts new file mode 100644 index 0000000..4448b89 --- /dev/null +++ b/src/utils/validators/is-future-time-string.validator.ts @@ -0,0 +1,48 @@ +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; +import * as dayjs from 'dayjs'; + +export function IsFutureTimeString(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isFutureTimeString', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + if (typeof value !== 'string') return false; + + const match = value.match(/^(\d+)([smhdwMy])$/); + if (!match) return false; + + const amount = parseInt(match[1], 10); + const unit = match[2]; + + const unitMap: Record = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year', + }; + + const now = dayjs(); + const future = now.add(amount, unitMap[unit]); + + // must be at least 1 minute ahead + return future.diff(now, 'minute', true) >= 1; + }, + + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid time string (e.g. 1h, 2d, 1M) and at least 1 minute in the future`; + }, + }, + }); + }; +}