Skip to content
Open
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
691 changes: 482 additions & 209 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1037.0",
"@aws-sdk/s3-request-presigner": "^3.1037.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-modules/ioredis": "^2.2.1",
"@nestjs/bull": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
Expand Down Expand Up @@ -68,7 +69,7 @@
"prom-client": "^15.1.3",
"qrcode": "^1.5.1",
"redis": "^5.12.1",
"reflect-metadata": "^0.1.13",
"reflect-metadata": "^0.2.2",
"rimraf": "^5.0.0",
"rxjs": "^7.8.0",
"sharp": "^0.34.5",
Expand All @@ -88,6 +89,7 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.40.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"jest-util": "^29.7.0",
Expand All @@ -101,4 +103,4 @@
"typescript": "^5.1.0",
"typescript-eslint": "^8.62.1"
}
}
}
13 changes: 4 additions & 9 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nestjs/throttler-storage-redis';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { RateLimitGuard } from './common/guards/rate-limit.guard';
import { AppController } from './app.controller';
Expand All @@ -23,10 +23,10 @@ import { AdminModule } from '@modules/admin/admin.module';
import { ReportsModule } from '@modules/reports/reports.module';
import { GamificationModule } from './modules/gamification/gamification.module';
// 1. Import the new StorageModule
import { StorageModule } from './shared/storage/storage.module';
import { StorageModule } from './shared/storage/storage.module';
import { MetricsModule } from './shared/metrics/metrics.module';
import { UsageModule } from './modules/usage/usage.module';
import { MonitoringModule } from './shared/monitoring/monitoring.module';
import { MonitoringModule } from './shared/monitoring/monitoring.module';
import { CacheModule } from './shared/cache/cache.module';
import { CouponModule } from './coupons/coupon.module'; // <-- Added CouponModule import

Expand Down Expand Up @@ -110,15 +110,13 @@ import { NotificationCenterModule } from './modules/notification-center/notifica
NotificationsModule,
AdminModule,
ReportsModule,
feat/gamification-engine
GamificationModule,

RewardModule,
ReferralModule,
HealthProfileModule,
CouponModule, // <-- Registered CouponModule in active application imports tree
NotificationCenterModule,
main
],
controllers: [AppController],
providers: [
Expand All @@ -129,11 +127,8 @@ import { NotificationCenterModule } from './modules/notification-center/notifica
},
],
})
feat/gamification-engine
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
main
3 changes: 1 addition & 2 deletions src/common/dto/create-health-task.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum, IsDate, IsNotEmpty } from 'class-validator';
import { IsString, IsOptional, IsEnum, IsDate, IsNotEmpty, IsUUID, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum TaskCategory {
Expand All @@ -11,7 +11,6 @@ export enum TaskCategory {
MEDICATION = 'medication',
OTHER = 'other',
}
import { IsString, IsOptional, IsEnum, IsDate, IsNotEmpty, IsUUID, IsArray } from 'class-validator';

export enum TaskPriority {
LOW = 'low',
Expand Down
16 changes: 4 additions & 12 deletions src/common/interceptors/activity-tracker.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import Redis from 'ioredis';
import { ConfigService } from '@nestjs/config';
import { redisConfig, getRedisUrl } from '../../config/redis.config';
import { UsersService } from '../../users/users.service';
import { UsersService } from '../../modules/users/users.service';

@Injectable()
export class ActivityTrackerInterceptor implements NestInterceptor {
Expand All @@ -21,7 +15,7 @@ export class ActivityTrackerInterceptor implements NestInterceptor {

constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
private readonly usersService: UsersService
) {
const config = redisConfig(configService);
this.redis = new Redis(getRedisUrl(config));
Expand All @@ -33,9 +27,7 @@ export class ActivityTrackerInterceptor implements NestInterceptor {

if (user && user.id) {
this.trackActivity(user.id).catch((err) => {
this.logger.error(
`Failed to track activity for user ${user.id}: ${err.message}`,
);
this.logger.error(`Failed to track activity for user ${user.id}: ${err.message}`);
});
}

Expand Down
16 changes: 5 additions & 11 deletions src/coupons/coupon.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,9 @@ import {
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { Coupon } from './entities/coupon.entity';
import { Coupon } from '../entities/coupon.entity';
import { CouponService, ValidateCouponResult } from './coupon.service';
import { ValidateCouponDto } from './dto/validate-coupon.dto';

Expand Down Expand Up @@ -47,8 +42,7 @@ export class CouponController {
@Get('me')
@ApiOperation({
summary: "Get current user's active coupons",
description:
"Returns the authenticated user's active (non-expired) coupons.",
description: "Returns the authenticated user's active (non-expired) coupons.",
})
@ApiResponse({ status: 200, description: 'List of active coupons' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
Expand All @@ -63,7 +57,7 @@ export class CouponController {
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
})
)
@ApiOperation({
summary: 'Validate coupon',
Expand Down Expand Up @@ -95,7 +89,7 @@ export class CouponController {
})
async validate(
@Req() req: AuthenticatedRequest,
@Body() dto: ValidateCouponDto,
@Body() dto: ValidateCouponDto
): Promise<ValidateCouponResult> {
return this.couponService.validate(dto, req.user.sub);
}
Expand Down
92 changes: 40 additions & 52 deletions src/coupons/coupon.service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import {
ForbiddenException,
Injectable,
Logger,
OnModuleInit,
} from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Redis from 'ioredis';
import { Cron } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { redisConfig, getRedisUrl } from '../config/redis.config';
import { Coupon, CouponStatus } from './entities/coupon.entity';
import { Coupon, CouponStatus } from '../entities/coupon.entity';
import { ValidateCouponDto } from './dto/validate-coupon.dto';
import {
REWARD_MILESTONE_EVENT,
RewardMilestonePayload,
} from './coupon.events';
import { REWARD_MILESTONE_EVENT, RewardMilestonePayload } from './coupon.events';

export interface ValidateCouponResult {
valid: boolean;
Expand All @@ -39,49 +31,40 @@ export class CouponService implements OnModuleInit {
@InjectRepository(Coupon)
private readonly couponRepository: Repository<Coupon>,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
private readonly eventEmitter: EventEmitter2
) {
const config = redisConfig(configService);
this.redis = new Redis(getRedisUrl(config));
}

onModuleInit() {
this.eventEmitter.on(
REWARD_MILESTONE_EVENT,
async (payload: RewardMilestonePayload) => {
if (!payload?.userId) {
this.logger.warn(
'reward.milestone event received with no userId; skipping',
);
return;
}
try {
await this.createForMilestone(payload.userId);
this.logger.log(
`Coupon created for milestone for user ${payload.userId}`,
);
} catch (error) {
this.logger.error(
`Failed to create coupon for milestone: ${(error as Error)?.message}`,
);
}
},
);
this.eventEmitter.on(REWARD_MILESTONE_EVENT, async (payload: RewardMilestonePayload) => {
if (!payload?.userId) {
this.logger.warn('reward.milestone event received with no userId; skipping');
return;
}
try {
await this.createForMilestone(payload.userId);
this.logger.log(`Coupon created for milestone for user ${payload.userId}`);
} catch (error) {
this.logger.error(`Failed to create coupon for milestone: ${(error as Error)?.message}`);
}
});
}

/**
* Create a coupon when user reaches an XLM milestone. Enforces max 5 active coupons per user.
*/
async createForMilestone(
userId: string,
payload?: { specialistType?: string; discount?: number },
payload?: { specialistType?: string; discount?: number }
): Promise<Coupon | null> {
const activeCount = await this.couponRepository.count({
where: { userId, status: CouponStatus.ACTIVE },
});
if (activeCount >= MAX_ACTIVE_COUPONS_PER_USER) {
this.logger.warn(
`User ${userId} already has ${MAX_ACTIVE_COUPONS_PER_USER} active coupons; skipping creation`,
`User ${userId} already has ${MAX_ACTIVE_COUPONS_PER_USER} active coupons; skipping creation`
);
return null;
}
Expand Down Expand Up @@ -115,6 +98,23 @@ export class CouponService implements OnModuleInit {
.then((list) => list.filter((c) => c.expiresAt > now));
}

/**
* Active coupons that expire within the next `hours` hours (used by
* expiry-reminder schedulers).
*/
async findExpiringWithinHours(hours: number): Promise<Coupon[]> {
const now = new Date();
const cutoff = new Date(now.getTime() + hours * 3600 * 1000);
return this.couponRepository
.find({
where: {
status: CouponStatus.ACTIVE,
},
order: { expiresAt: 'ASC' },
})
.then((list) => list.filter((c) => c.expiresAt > now && c.expiresAt <= cutoff));
}

/**
* Nightly cron: mark expired coupons via QueryBuilder bulk update.
*/
Expand All @@ -128,30 +128,23 @@ export class CouponService implements OnModuleInit {
.andWhere('expiresAt < :now', { now: new Date() })
.execute();

this.logger.log(
`Marked ${result.affected ?? 0} expired coupons as EXPIRED`,
);
this.logger.log(`Marked ${result.affected ?? 0} expired coupons as EXPIRED`);
}

/**
* Validate a coupon before confirming a consultation booking.
* Rate limited: max 10 validation attempts per coupon per hour (Redis counter).
* Does NOT mark the coupon as used.
*/
async validate(
dto: ValidateCouponDto,
currentUserId: string,
): Promise<ValidateCouponResult> {
async validate(dto: ValidateCouponDto, currentUserId: string): Promise<ValidateCouponResult> {
const normalizedCode = dto.code.trim().toUpperCase();
const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}${normalizedCode}`;

const attemptCount = await this.redis.get(rateLimitKey);
const currentCount = attemptCount ? parseInt(attemptCount, 10) : 0;

if (currentCount >= MAX_VALIDATION_ATTEMPTS_PER_HOUR) {
this.logger.warn(
`Coupon validation rate limit exceeded for code: ${normalizedCode}`,
);
this.logger.warn(`Coupon validation rate limit exceeded for code: ${normalizedCode}`);
return { valid: false, reason: 'rate_limit_exceeded' };
}

Expand All @@ -171,17 +164,12 @@ export class CouponService implements OnModuleInit {
return { valid: false, reason: 'already_used' };
}

if (
coupon.status === CouponStatus.EXPIRED ||
new Date() > coupon.expiresAt
) {
if (coupon.status === CouponStatus.EXPIRED || new Date() > coupon.expiresAt) {
return { valid: false, reason: 'expired' };
}

if (coupon.userId !== currentUserId) {
throw new ForbiddenException(
'Coupon does not belong to the current user',
);
throw new ForbiddenException('Coupon does not belong to the current user');
}

return { valid: true };
Expand Down
23 changes: 21 additions & 2 deletions src/entities/coupon.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
} from 'typeorm';
import { User } from './user.entity';

export enum CouponStatus {
ACTIVE = 'active',
REDEEMED = 'redeemed',
EXPIRED = 'expired',
}

@Entity('coupons')
export class Coupon {
@PrimaryGeneratedColumn('uuid')
Expand All @@ -23,12 +29,25 @@ export class Coupon {
@JoinColumn({ name: 'userId' })
user: User;

@Column({ type: 'int', default: 10 })
discount: number; // percentage

@Column({ type: 'varchar', length: 100, nullable: true })
specialistType: string | null;

@Column({ type: 'timestamp' })
expiresAt: Date;

@Column({ type: 'timestamp', nullable: true })
@Column({ nullable: true })
usedAt: Date | null;

@Column({
type: 'enum',
enum: CouponStatus,
default: CouponStatus.ACTIVE,
})
status: CouponStatus;

@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
}
}
Loading