Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/commons/interfaces/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
14 changes: 13 additions & 1 deletion src/messenger/dto/create-messenger.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -11,5 +18,10 @@ export class CreateMessengerDto {

@IsDateString({ strict: true })
@IsNotEmpty()
@IsOptional()
send_at: string;

@IsFutureTimeString()
@IsOptional()
send_after: string;
}
53 changes: 36 additions & 17 deletions src/messenger/messenger.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
BadRequestException,
GoneException,
Injectable,
Logger,
NotFoundException,
Expand All @@ -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(
Expand All @@ -27,20 +26,37 @@ export class MessengerService {
@InjectQueue(QUEUE_NAME.MESSAGE_TO_FUTURE)
private messageToFutureQueue: Queue,
private readonly emailService: EmailService,
private readonly configService: ConfigService<IENV, true>,
) {}

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,
});

Expand All @@ -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(
Expand All @@ -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',
);
}

Expand Down Expand Up @@ -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);
Expand Down
132 changes: 132 additions & 0 deletions src/utils/time.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as dayjs from 'dayjs';

type TimeUnit = 's' | 'm' | 'h' | 'd' | 'w' | 'y';

const UNIT_TO_MS: Record<TimeUnit, number> = {
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<string, dayjs.ManipulateType> = {
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<string, dayjs.ManipulateType> = {
s: 'second',
m: 'minute',
h: 'hour',
d: 'day',
w: 'week',
M: 'month',
y: 'year',
};

return dayjs().add(parseInt(amountStr, 10), unitMap[unit]).valueOf();
}
48 changes: 48 additions & 0 deletions src/utils/validators/is-future-time-string.validator.ts
Original file line number Diff line number Diff line change
@@ -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<string, dayjs.ManipulateType> = {
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`;
},
},
});
};
}
Loading