gcx/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts

341 lines
11 KiB
TypeScript

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<Coupon>,
private readonly dataSource: DataSource,
) {}
async findById(id: string): Promise<Coupon | null> {
return this.repo.findOne({ where: { id } });
}
async create(data: Partial<Coupon>): Promise<Coupon> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async save(coupon: Coupon): Promise<Coupon> {
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<OwnerSummary> {
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<Coupon> {
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<Coupon> {
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<Record<string, any>>): Promise<number> {
return this.repo.count({ where: where as any });
}
// ---- Analytics aggregates ----
async getAggregateStats(): Promise<CouponAggregateResult> {
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<CouponStatusCount[]> {
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<CouponsByIssuerRow[]> {
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<CouponsByCategoryRow[]> {
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<number> {
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<number> {
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<RedemptionRateRow[]> {
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<DiscountDistributionRow[]> {
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<CouponSoldAggregateRow[]> {
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<Coupon[]> {
return this.repo
.createQueryBuilder('c')
.where('c.total_supply > c.remaining_supply')
.orderBy('c.updated_at', 'DESC')
.take(limit)
.getMany();
}
}