import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; import { Issuer } from '../../domain/entities/issuer.entity'; import { ICouponRepository, CouponListFilters, CouponAggregateResult, CouponStatusCount, CouponsByIssuerRow, CouponsByCategoryRow, CouponSoldAggregateRow, DiscountDistributionRow, RedemptionRateRow, OwnerSummary, } from '../../domain/repositories/coupon.repository.interface'; @Injectable() export class CouponRepository implements ICouponRepository { constructor( @InjectRepository(Coupon) private readonly repo: Repository, private readonly dataSource: DataSource, ) {} async findById(id: string): Promise { return this.repo.findOne({ where: { id } }); } async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); } async save(coupon: Coupon): Promise { return this.repo.save(coupon); } async findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]> { const { category, status, search, issuerId, page, limit } = filters; const qb = this.repo.createQueryBuilder('c'); if (category) qb.andWhere('c.category = :category', { category }); if (status) qb.andWhere('c.status = :status', { status }); if (issuerId) qb.andWhere('c.issuer_id = :issuerId', { issuerId }); if (search) { qb.andWhere('(c.name ILIKE :search OR c.description ILIKE :search)', { search: `%${search}%`, }); } qb.orderBy('c.created_at', 'DESC') .skip((page - 1) * limit) .take(limit); return qb.getManyAndCount(); } async findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]> { const { category, status, search, issuerId, page, limit } = filters; const qb = this.repo.createQueryBuilder('c'); qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id'); if (status) qb.andWhere('c.status = :status', { status }); if (issuerId) qb.andWhere('c.issuer_id = :issuerId', { issuerId }); if (category) qb.andWhere('c.category = :category', { category }); if (search) { qb.andWhere('(c.name ILIKE :search OR c.description ILIKE :search)', { search: `%${search}%`, }); } qb.orderBy('c.created_at', 'DESC') .skip((page - 1) * limit) .take(limit); return qb.getManyAndCount(); } async findByOwnerWithIssuerJoin(userId: string, filters: CouponListFilters): Promise<[Coupon[], number]> { const { status, page, limit } = filters; const qb = this.repo.createQueryBuilder('c'); qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id'); qb.andWhere('c.owner_user_id = :userId', { userId }); if (status) qb.andWhere('c.status = :status', { status }); qb.orderBy('c.created_at', 'DESC') .skip((page - 1) * limit) .take(limit); return qb.getManyAndCount(); } async getOwnerSummary(userId: string): Promise { const result = await this.repo .createQueryBuilder('c') .select([ 'COUNT(c.id) as "count"', 'COALESCE(SUM(CAST(c.face_value AS numeric)), 0) as "totalFaceValue"', 'COALESCE(SUM(CAST(c.face_value AS numeric) - COALESCE(CAST(c.current_price AS numeric), CAST(c.face_value AS numeric))), 0) as "totalSaved"', ]) .where('c.owner_user_id = :userId', { userId }) .getRawOne(); return { count: Number(result.count) || 0, totalFaceValue: Number(result.totalFaceValue) || 0, totalSaved: Number(result.totalSaved) || 0, }; } async updateStatus(id: string, status: string): Promise { const coupon = await this.repo.findOne({ where: { id } }); if (!coupon) throw new NotFoundException('Coupon not found'); coupon.status = status; return this.repo.save(coupon); } async purchaseWithLock(couponId: string, quantity: number): Promise { return this.dataSource.transaction(async (manager) => { const coupon = await manager.findOne(Coupon, { where: { id: couponId }, lock: { mode: 'pessimistic_write' }, }); if (!coupon) throw new NotFoundException('Coupon not found'); if (coupon.status !== CouponStatus.LISTED) { throw new BadRequestException('Coupon is not available'); } if (coupon.remainingSupply < quantity) { throw new BadRequestException('Insufficient supply'); } coupon.remainingSupply -= quantity; if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD; await manager.save(coupon); return coupon; }); } async count(where?: Partial>): Promise { return this.repo.count({ where: where as any }); } // ---- Analytics aggregates ---- async getAggregateStats(): Promise { const result = await this.repo .createQueryBuilder('c') .select([ 'COUNT(c.id) as "totalCoupons"', 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', 'COALESCE(SUM(c.remaining_supply), 0) as "totalRemaining"', ]) .getRawOne(); return { totalCoupons: Number(result.totalCoupons) || 0, totalSupply: Number(result.totalSupply) || 0, totalSold: Number(result.totalSold) || 0, totalRemaining: Number(result.totalRemaining) || 0, }; } async getStatusCounts(): Promise { const raw = await this.repo .createQueryBuilder('c') .select('c.status', 'status') .addSelect('COUNT(c.id)', 'count') .groupBy('c.status') .getRawMany(); return raw.map((row) => ({ status: row.status, count: Number(row.count), })); } async getCouponsByIssuer(): Promise { const raw = await this.repo .createQueryBuilder('c') .select([ 'c.issuer_id as "issuerId"', 'COUNT(c.id) as "couponCount"', 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', 'COALESCE(SUM(CAST(c.face_value AS numeric) * c.total_supply), 0) as "totalFaceValue"', ]) .groupBy('c.issuer_id') .orderBy('"couponCount"', 'DESC') .getRawMany(); return raw.map((row) => ({ issuerId: row.issuerId, couponCount: Number(row.couponCount), totalSupply: Number(row.totalSupply), totalSold: Number(row.totalSold), totalFaceValue: Number(row.totalFaceValue), })); } async getCouponsByCategory(): Promise { const raw = await this.repo .createQueryBuilder('c') .select([ 'c.category as "category"', 'COUNT(c.id) as "couponCount"', 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', 'COALESCE(AVG(CAST(c.current_price AS numeric)), 0) as "avgPrice"', ]) .groupBy('c.category') .orderBy('"couponCount"', 'DESC') .getRawMany(); return raw.map((row) => ({ category: row.category, couponCount: Number(row.couponCount), totalSupply: Number(row.totalSupply), totalSold: Number(row.totalSold), avgPrice: Math.round(Number(row.avgPrice) * 100) / 100, })); } async getTotalSold(): Promise { const result = await this.repo .createQueryBuilder('c') .select('COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"') .getRawOne(); return Number(result.totalSold) || 0; } async getTotalSoldByStatuses(statuses: CouponStatus[]): Promise { const result = await this.repo .createQueryBuilder('c') .select('COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalRedeemed"') .where('c.status IN (:...statuses)', { statuses }) .getRawOne(); return Number(result.totalRedeemed) || 0; } async getRedemptionRateTrend(): Promise { const raw = await this.repo .createQueryBuilder('c') .select([ "TO_CHAR(c.created_at, 'YYYY-MM') as \"month\"", 'COALESCE(SUM(c.total_supply), 0) as "totalIssued"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', ]) .groupBy("TO_CHAR(c.created_at, 'YYYY-MM')") .orderBy('"month"', 'ASC') .getRawMany(); return raw.map((row) => ({ month: row.month, totalIssued: Number(row.totalIssued) || 0, totalSold: Number(row.totalSold) || 0, })); } async getDiscountDistribution(): Promise { const raw = await this.repo .createQueryBuilder('c') .select([ `CASE WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A' WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium' WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%' WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%' WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%' WHEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%' ELSE '50%+' END as "range"`, 'COUNT(c.id) as "count"', `COALESCE(AVG( CASE WHEN CAST(c.face_value AS numeric) > 0 THEN (1 - CAST(c.current_price AS numeric) / CAST(c.face_value AS numeric)) * 100 ELSE 0 END ), 0) as "avgDiscount"`, ]) .groupBy('"range"') .orderBy('"range"', 'ASC') .getRawMany(); return raw.map((row) => ({ range: row.range, count: Number(row.count), avgDiscount: Math.round(Number(row.avgDiscount) * 100) / 100, })); } // ---- Merchant analytics ---- async getCouponSupplyAggregate(): Promise<{ totalCouponsIssued: number; totalCouponsSold: number }> { const result = await this.repo .createQueryBuilder('c') .select([ 'COALESCE(SUM(c.total_supply), 0) as "totalCouponsIssued"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalCouponsSold"', ]) .getRawOne(); return { totalCouponsIssued: Number(result.totalCouponsIssued) || 0, totalCouponsSold: Number(result.totalCouponsSold) || 0, }; } async getSoldPerIssuer(issuerIds: string[]): Promise { if (issuerIds.length === 0) return []; const raw = await this.repo .createQueryBuilder('c') .select([ 'c.issuer_id as "issuerId"', 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', ]) .where('c.issuer_id IN (:...ids)', { ids: issuerIds }) .groupBy('c.issuer_id') .getRawMany(); return raw.map((r) => ({ issuerId: r.issuerId, totalSold: Number(r.totalSold), })); } async getRecentlySoldCoupons(limit: number): Promise { return this.repo .createQueryBuilder('c') .where('c.total_supply > c.remaining_supply') .orderBy('c.updated_at', 'DESC') .take(limit) .getMany(); } }