feat: implement recovery incident service and controller with metric aggregation and CRUD operations
This commit is contained in:
@@ -22,12 +22,27 @@ export class RecoveryIncidentController {
|
||||
return this.recoveryIncidentService.findAll();
|
||||
}
|
||||
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: 'Get recovery incidents metrics summary' })
|
||||
getMetrics() {
|
||||
return this.recoveryIncidentService.getMetrics();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a recovery incident by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.recoveryIncidentService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({ summary: 'Update status of a recovery incident' })
|
||||
updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: string,
|
||||
) {
|
||||
return this.recoveryIncidentService.updateStatus(id, status);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a recovery incident' })
|
||||
update(@Param('id') id: string, @Body() updateDto: UpdateRecoveryIncidentDto) {
|
||||
|
||||
@@ -6,6 +6,16 @@ import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto';
|
||||
import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
|
||||
import { getTenantId } from '../../common/tenant/tenant.context';
|
||||
|
||||
export interface MetricCardData {
|
||||
id?: string;
|
||||
title: string;
|
||||
value?: string;
|
||||
trendText: string;
|
||||
trendValue: string;
|
||||
trendType: 'positive' | 'negative' | 'neutral';
|
||||
sparklineColor: 'green' | 'red';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecoveryIncidentService {
|
||||
constructor(
|
||||
@@ -31,6 +41,181 @@ export class RecoveryIncidentService {
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<MetricCardData[]> {
|
||||
const tenantId = getTenantId();
|
||||
const incidents = await this.recoveryIncidentRepo.find({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const getIncidentDate = (i: RecoveryIncident): Date => {
|
||||
if (i.date) return new Date(i.date);
|
||||
if (i.createdAt) return new Date(i.createdAt);
|
||||
return now;
|
||||
};
|
||||
|
||||
const currentWeekIncidents = incidents.filter((i) => getIncidentDate(i) >= sevenDaysAgo);
|
||||
const previousWeekIncidents = incidents.filter((i) => {
|
||||
const d = getIncidentDate(i);
|
||||
return d >= fourteenDaysAgo && d < sevenDaysAgo;
|
||||
});
|
||||
|
||||
// Helper for parsing currency string into number
|
||||
const parseValue = (val?: string): number => {
|
||||
if (!val) return 0;
|
||||
const num = parseFloat(val.replace(/[^0-9.]/g, '')) || 0;
|
||||
return num;
|
||||
};
|
||||
|
||||
// Helper for formatting sum into string (e.g. $412k, $1.2M, $500)
|
||||
const formatCurrency = (amount: number): string => {
|
||||
if (amount >= 1000000) {
|
||||
return `$${(amount / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||
}
|
||||
if (amount >= 1000) {
|
||||
return `$${Math.round(amount / 1000)}k`;
|
||||
}
|
||||
return `$${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
// Helper to check if an incident is satisfied
|
||||
const isSatisfied = (i: RecoveryIncident): boolean => {
|
||||
const s = (i.status || '').toLowerCase();
|
||||
return s.includes('appr') || s.includes('active') || s.includes('success') || Boolean(i.isPerksClaimed);
|
||||
};
|
||||
|
||||
// Helper to check if an incident is pending
|
||||
const isPending = (i: RecoveryIncident): boolean => {
|
||||
const s = (i.status || '').toLowerCase();
|
||||
return s.includes('pending') || s.includes('review') || s.includes('new');
|
||||
};
|
||||
|
||||
// 1. Total Recoveries
|
||||
const totalCount = incidents.length;
|
||||
const currentWeekTotal = currentWeekIncidents.length;
|
||||
const previousWeekTotal = previousWeekIncidents.length;
|
||||
let totalTrendVal = '0%';
|
||||
let totalTrendType: 'positive' | 'negative' | 'neutral' = 'neutral';
|
||||
let totalSparkColor: 'green' | 'red' = 'green';
|
||||
|
||||
if (previousWeekTotal > 0) {
|
||||
const pct = Math.round(((currentWeekTotal - previousWeekTotal) / previousWeekTotal) * 100);
|
||||
totalTrendVal = `${pct >= 0 ? '+' : ''}${pct}%`;
|
||||
totalTrendType = pct >= 0 ? 'positive' : 'negative';
|
||||
totalSparkColor = pct >= 0 ? 'green' : 'red';
|
||||
} else if (currentWeekTotal > 0) {
|
||||
totalTrendVal = '+100%';
|
||||
totalTrendType = 'positive';
|
||||
totalSparkColor = 'green';
|
||||
}
|
||||
|
||||
// 2. Pending Approval
|
||||
const pendingIncidents = incidents.filter(isPending);
|
||||
const pendingCount = pendingIncidents.length;
|
||||
const currentWeekPending = currentWeekIncidents.filter(isPending).length;
|
||||
const previousWeekPending = previousWeekIncidents.filter(isPending).length;
|
||||
let pendingTrendVal = '0%';
|
||||
let pendingTrendType: 'positive' | 'negative' | 'neutral' = 'neutral';
|
||||
let pendingSparkColor: 'green' | 'red' = 'green';
|
||||
|
||||
if (previousWeekPending > 0) {
|
||||
const pct = Math.round(((currentWeekPending - previousWeekPending) / previousWeekPending) * 100);
|
||||
pendingTrendVal = `${pct >= 0 ? '+' : ''}${pct}%`;
|
||||
pendingTrendType = pct >= 0 ? 'positive' : 'negative';
|
||||
pendingSparkColor = pct >= 0 ? 'green' : 'red';
|
||||
} else if (pendingCount > 0) {
|
||||
pendingTrendVal = 'High Priority';
|
||||
pendingTrendType = 'positive';
|
||||
pendingSparkColor = 'green';
|
||||
}
|
||||
|
||||
// 3. Refund Value
|
||||
const totalRefundSum = incidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
|
||||
const currentWeekRefundSum = currentWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
|
||||
const previousWeekRefundSum = previousWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
|
||||
let refundTrendVal = '0%';
|
||||
let refundTrendType: 'positive' | 'negative' | 'neutral' = 'neutral';
|
||||
let refundSparkColor: 'green' | 'red' = 'green';
|
||||
|
||||
if (previousWeekRefundSum > 0) {
|
||||
const pct = Math.round(((currentWeekRefundSum - previousWeekRefundSum) / previousWeekRefundSum) * 100);
|
||||
refundTrendVal = `${pct >= 0 ? '+' : ''}${pct}%`;
|
||||
refundTrendType = pct >= 0 ? 'positive' : 'negative';
|
||||
refundSparkColor = pct >= 0 ? 'green' : 'red';
|
||||
} else if (currentWeekRefundSum > 0) {
|
||||
refundTrendVal = '+100%';
|
||||
refundTrendType = 'positive';
|
||||
refundSparkColor = 'green';
|
||||
}
|
||||
|
||||
// 4. Customer Satisfaction
|
||||
const satisfiedCount = incidents.filter(isSatisfied).length;
|
||||
const satisfactionRate = totalCount > 0 ? Math.round((satisfiedCount / totalCount) * 100) : 0;
|
||||
|
||||
const currentSatisfied = currentWeekIncidents.filter(isSatisfied).length;
|
||||
const currentSatRate = currentWeekIncidents.length > 0 ? Math.round((currentSatisfied / currentWeekIncidents.length) * 100) : 0;
|
||||
|
||||
const previousSatisfied = previousWeekIncidents.filter(isSatisfied).length;
|
||||
const previousSatRate = previousWeekIncidents.length > 0 ? Math.round((previousSatisfied / previousWeekIncidents.length) * 100) : 0;
|
||||
|
||||
let satTrendVal = '0%';
|
||||
let satTrendType: 'positive' | 'negative' | 'neutral' = 'neutral';
|
||||
let satSparkColor: 'green' | 'red' = 'green';
|
||||
|
||||
if (previousWeekIncidents.length > 0 && currentWeekIncidents.length > 0) {
|
||||
const diff = currentSatRate - previousSatRate;
|
||||
satTrendVal = `${diff >= 0 ? '+' : ''}${diff}%`;
|
||||
satTrendType = diff >= 0 ? 'positive' : 'negative';
|
||||
satSparkColor = diff >= 0 ? 'green' : 'red';
|
||||
} else if (satisfactionRate > 0) {
|
||||
satTrendVal = `+${satisfactionRate}%`;
|
||||
satTrendType = 'positive';
|
||||
satSparkColor = 'green';
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total-recoveries',
|
||||
title: 'Total Recoveries',
|
||||
value: totalCount.toLocaleString(),
|
||||
trendValue: totalTrendVal,
|
||||
trendText: 'since last week',
|
||||
trendType: totalTrendType,
|
||||
sparklineColor: totalSparkColor,
|
||||
},
|
||||
{
|
||||
id: 'pending-approval',
|
||||
title: 'Pending Approval',
|
||||
value: pendingCount.toLocaleString(),
|
||||
trendValue: pendingTrendVal,
|
||||
trendText: 'since last week',
|
||||
trendType: pendingTrendType,
|
||||
sparklineColor: pendingSparkColor,
|
||||
},
|
||||
{
|
||||
id: 'refund-value',
|
||||
title: 'Refund Value',
|
||||
value: formatCurrency(totalRefundSum),
|
||||
trendValue: refundTrendVal,
|
||||
trendText: 'since last week',
|
||||
trendType: refundTrendType,
|
||||
sparklineColor: refundSparkColor,
|
||||
},
|
||||
{
|
||||
id: 'customer-satisfaction',
|
||||
title: 'Customer Satisfaction',
|
||||
value: `${satisfactionRate}%`,
|
||||
trendValue: satTrendVal,
|
||||
trendText: 'since last week',
|
||||
trendType: satTrendType,
|
||||
sparklineColor: satSparkColor,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<RecoveryIncident> {
|
||||
const tenantId = getTenantId();
|
||||
const incident = await this.recoveryIncidentRepo.findOne({
|
||||
@@ -56,6 +241,12 @@ export class RecoveryIncidentService {
|
||||
return this.recoveryIncidentRepo.save(incident);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string): Promise<RecoveryIncident> {
|
||||
const incident = await this.findOne(id);
|
||||
incident.status = status;
|
||||
return this.recoveryIncidentRepo.save(incident);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const incident = await this.findOne(id);
|
||||
await this.recoveryIncidentRepo.remove(incident);
|
||||
|
||||
Reference in New Issue
Block a user