341 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|