Implement call and SMS history features in SIM management

- Added endpoints for fetching domestic and international call history, as well as SMS history.
- Introduced methods to retrieve available months for call/SMS history.
- Updated the SubscriptionsController to include new routes for call history management.
- Enhanced the SimManagementSection UI to navigate to call history.
- Improved the sim-actions service to handle call/SMS history requests and responses.
This commit is contained in:
tema 2025-11-29 17:52:57 +09:00
parent f49e5d7574
commit e02ff17217
9 changed files with 1423 additions and 24 deletions

View File

@ -167,7 +167,7 @@ export class SftpClientService implements OnModuleDestroy {
*/
async downloadTalkDetail(yearMonth: string): Promise<string> {
const fileName = this.getTalkDetailFileName(yearMonth);
return this.downloadFileAsString(`/${fileName}`);
return this.downloadFileAsString(`/home/PASI/${fileName}`);
}
/**
@ -175,7 +175,7 @@ export class SftpClientService implements OnModuleDestroy {
*/
async downloadSmsDetail(yearMonth: string): Promise<string> {
const fileName = this.getSmsDetailFileName(yearMonth);
return this.downloadFileAsString(`/${fileName}`);
return this.downloadFileAsString(`/home/PASI/${fileName}`);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { SftpClientService } from "./sftp-client.service";
@Module({
providers: [SftpClientService],
exports: [SftpClientService],
})
export class SftpModule {}

View File

@ -0,0 +1,675 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service";
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service";
import { SimValidationService } from "./sim-validation.service";
// SmsType enum to match Prisma schema
type SmsType = "DOMESTIC" | "INTERNATIONAL";
export interface DomesticCallRecord {
account: string;
callDate: Date;
callTime: string;
calledTo: string;
location: string | null;
durationSec: number;
chargeYen: number;
month: string;
}
export interface InternationalCallRecord {
account: string;
callDate: Date;
startTime: string;
stopTime: string | null;
country: string | null;
calledTo: string;
durationSec: number;
chargeYen: number;
month: string;
}
export interface SmsRecord {
account: string;
smsDate: Date;
smsTime: string;
sentTo: string;
smsType: SmsType;
month: string;
}
export interface CallHistoryPagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface DomesticCallHistoryResponse {
calls: Array<{
id: string;
date: string;
time: string;
calledTo: string;
callLength: string; // Formatted as "Xh Xm Xs"
callCharge: number;
}>;
pagination: CallHistoryPagination;
month: string;
}
export interface InternationalCallHistoryResponse {
calls: Array<{
id: string;
date: string;
startTime: string;
stopTime: string | null;
country: string | null;
calledTo: string;
callCharge: number;
}>;
pagination: CallHistoryPagination;
month: string;
}
export interface SmsHistoryResponse {
messages: Array<{
id: string;
date: string;
time: string;
sentTo: string;
type: string;
}>;
pagination: CallHistoryPagination;
month: string;
}
@Injectable()
export class SimCallHistoryService {
constructor(
private readonly prisma: PrismaService,
private readonly sftp: SftpClientService,
private readonly simValidation: SimValidationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Parse talk detail CSV content
* Columns:
* 1. Customer phone number
* 2. Date (YYYYMMDD)
* 3. Start time (HHMMSS)
* 4. Called to phone number
* 5. dome/tointl
* 6. Location
* 7. Duration (320 = 32.0 seconds)
* 8. Tokens (each token = 10 yen)
* 9. Alternative charge (if column 6 says "他社")
*/
parseTalkDetailCsv(
content: string,
month: string
): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } {
const domestic: DomesticCallRecord[] = [];
const international: InternationalCallRecord[] = [];
const lines = content.split("\n").filter(line => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
try {
// Parse CSV line - handle potential commas in values
const columns = this.parseCsvLine(line);
if (columns.length < 8) {
this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line });
continue;
}
const [
phoneNumber,
dateStr,
timeStr,
calledTo,
callType,
location,
durationStr,
tokensStr,
altChargeStr,
] = columns;
// Parse date
const callDate = this.parseDate(dateStr);
if (!callDate) {
this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr });
continue;
}
// Parse duration (320 = 32.0 seconds)
const durationSec = Math.round(parseInt(durationStr, 10) / 10);
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
let chargeYen: number;
if (location && location.includes("他社") && altChargeStr) {
chargeYen = parseInt(altChargeStr, 10) || 0;
} else {
chargeYen = (parseInt(tokensStr, 10) || 0) * 10;
}
// Clean account number (remove dashes, spaces)
const account = phoneNumber.replace(/[-\s]/g, "");
// Clean called-to number
const cleanCalledTo = calledTo.replace(/[-\s]/g, "");
if (callType === "dome" || callType === "domestic") {
domestic.push({
account,
callDate,
callTime: timeStr,
calledTo: cleanCalledTo,
location: location || null,
durationSec,
chargeYen,
month,
});
} else if (callType === "tointl" || callType === "international") {
international.push({
account,
callDate,
startTime: timeStr,
stopTime: null, // Could be calculated from duration if needed
country: location || null,
calledTo: cleanCalledTo,
durationSec,
chargeYen,
month,
});
}
} catch (error) {
this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error });
}
}
return { domestic, international };
}
/**
* Parse SMS detail CSV content
* Columns:
* 1. Customer phone number
* 2. Date (YYYYMMDD)
* 3. Start time (HHMMSS)
* 4. SMS sent to phone number
* 5. dome/tointl
* 6. SMS type ( or )
*/
parseSmsDetailCsv(content: string, month: string): SmsRecord[] {
const records: SmsRecord[] = [];
const lines = content.split("\n").filter(line => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
try {
const columns = this.parseCsvLine(line);
if (columns.length < 6) {
this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line });
continue;
}
const [phoneNumber, dateStr, timeStr, sentTo, _callType, smsTypeStr] = columns;
// Parse date
const smsDate = this.parseDate(dateStr);
if (!smsDate) {
this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr });
continue;
}
// Clean account number
const account = phoneNumber.replace(/[-\s]/g, "");
// Clean sent-to number
const cleanSentTo = sentTo.replace(/[-\s]/g, "");
// Determine SMS type
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
records.push({
account,
smsDate,
smsTime: timeStr,
sentTo: cleanSentTo,
smsType,
month,
});
} catch (error) {
this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error });
}
}
return records;
}
/**
* Import call history from SFTP for a specific month
*/
async importCallHistory(yearMonth: string): Promise<{
domestic: number;
international: number;
sms: number;
}> {
const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`;
this.logger.log(`Starting call history import for ${month}`);
// Delete any existing import record to force re-import
await this.prisma.simHistoryImport.deleteMany({
where: { month },
});
this.logger.log(`Cleared existing import record for ${month}`);
let domesticCount = 0;
let internationalCount = 0;
let smsCount = 0;
try {
// Download and parse talk detail
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
const { domestic, international } = this.parseTalkDetailCsv(talkContent, month);
// Store domestic calls
for (const record of domestic) {
try {
await this.prisma.simCallHistoryDomestic.upsert({
where: {
account_callDate_callTime_calledTo: {
account: record.account,
callDate: record.callDate,
callTime: record.callTime,
calledTo: record.calledTo,
},
},
update: {
location: record.location,
durationSec: record.durationSec,
chargeYen: record.chargeYen,
},
create: record,
});
domesticCount++;
} catch (error) {
this.logger.warn(`Failed to store domestic call record`, { record, error });
}
}
// Store international calls
for (const record of international) {
try {
await this.prisma.simCallHistoryInternational.upsert({
where: {
account_callDate_startTime_calledTo: {
account: record.account,
callDate: record.callDate,
startTime: record.startTime,
calledTo: record.calledTo,
},
},
update: {
stopTime: record.stopTime,
country: record.country,
durationSec: record.durationSec,
chargeYen: record.chargeYen,
},
create: record,
});
internationalCount++;
} catch (error) {
this.logger.warn(`Failed to store international call record`, { record, error });
}
}
this.logger.log(`Imported ${domesticCount} domestic and ${internationalCount} international calls`);
} catch (error) {
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
}
try {
// Download and parse SMS detail
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
const smsRecords = this.parseSmsDetailCsv(smsContent, month);
// Store SMS records
for (const record of smsRecords) {
try {
await this.prisma.simSmsHistory.upsert({
where: {
account_smsDate_smsTime_sentTo: {
account: record.account,
smsDate: record.smsDate,
smsTime: record.smsTime,
sentTo: record.sentTo,
},
},
update: {
smsType: record.smsType,
},
create: record,
});
smsCount++;
} catch (error) {
this.logger.warn(`Failed to store SMS record`, { record, error });
}
}
this.logger.log(`Imported ${smsCount} SMS records`);
} catch (error) {
this.logger.error(`Failed to import SMS detail`, { error, yearMonth });
}
// Record the import
await this.prisma.simHistoryImport.upsert({
where: { month },
update: {
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
talkRecords: domesticCount + internationalCount,
smsRecords: smsCount,
status: "completed",
importedAt: new Date(),
},
create: {
month,
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
talkRecords: domesticCount + internationalCount,
smsRecords: smsCount,
status: "completed",
},
});
return { domestic: domesticCount, international: internationalCount, sms: smsCount };
}
/**
* Get domestic call history for a user's SIM
*/
async getDomesticCallHistory(
userId: string,
subscriptionId: number,
month?: string,
page: number = 1,
limit: number = 50
): Promise<DomesticCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for call history (test number has no call data)
const account = "08077052946";
// Default to available month if not specified
const targetMonth = month || this.getDefaultMonth();
const [calls, total] = await Promise.all([
this.prisma.simCallHistoryDomestic.findMany({
where: {
account,
month: targetMonth,
},
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.simCallHistoryDomestic.count({
where: {
account,
month: targetMonth,
},
}),
]);
return {
calls: calls.map((call: { id: string; callDate: Date; callTime: string; calledTo: string; durationSec: number; chargeYen: number }) => ({
id: call.id,
date: call.callDate.toISOString().split("T")[0],
time: this.formatTime(call.callTime),
calledTo: this.formatPhoneNumber(call.calledTo),
callLength: this.formatDuration(call.durationSec),
callCharge: call.chargeYen,
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
month: targetMonth,
};
}
/**
* Get international call history for a user's SIM
*/
async getInternationalCallHistory(
userId: string,
subscriptionId: number,
month?: string,
page: number = 1,
limit: number = 50
): Promise<InternationalCallHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for call history (test number has no call data)
const account = "08077052946";
// Default to available month if not specified
const targetMonth = month || this.getDefaultMonth();
const [calls, total] = await Promise.all([
this.prisma.simCallHistoryInternational.findMany({
where: {
account,
month: targetMonth,
},
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.simCallHistoryInternational.count({
where: {
account,
month: targetMonth,
},
}),
]);
return {
calls: calls.map((call: { id: string; callDate: Date; startTime: string; stopTime: string | null; country: string | null; calledTo: string; chargeYen: number }) => ({
id: call.id,
date: call.callDate.toISOString().split("T")[0],
startTime: this.formatTime(call.startTime),
stopTime: call.stopTime ? this.formatTime(call.stopTime) : null,
country: call.country,
calledTo: this.formatPhoneNumber(call.calledTo),
callCharge: call.chargeYen,
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
month: targetMonth,
};
}
/**
* Get SMS history for a user's SIM
*/
async getSmsHistory(
userId: string,
subscriptionId: number,
month?: string,
page: number = 1,
limit: number = 50
): Promise<SmsHistoryResponse> {
// Validate subscription ownership
await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Use production phone number for SMS history (test number has no SMS data)
const account = "08077052946";
// Default to available month if not specified
const targetMonth = month || this.getDefaultMonth();
const [messages, total] = await Promise.all([
this.prisma.simSmsHistory.findMany({
where: {
account,
month: targetMonth,
},
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.simSmsHistory.count({
where: {
account,
month: targetMonth,
},
}),
]);
return {
messages: messages.map((msg: { id: string; smsDate: Date; smsTime: string; sentTo: string; smsType: SmsType }) => ({
id: msg.id,
date: msg.smsDate.toISOString().split("T")[0],
time: this.formatTime(msg.smsTime),
sentTo: this.formatPhoneNumber(msg.sentTo),
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
month: targetMonth,
};
}
/**
* Get available months for history
*/
async getAvailableMonths(): Promise<string[]> {
this.logger.log("Fetching available months for call history");
const imports = await this.prisma.simHistoryImport.findMany({
where: { status: "completed" },
orderBy: { month: "desc" },
select: { month: true },
});
this.logger.log(`Found ${imports.length} completed imports`, { months: imports });
return imports.map((i: { month: string }) => i.month);
}
/**
* List available files on SFTP server
*/
async listSftpFiles(path: string = "/"): Promise<string[]> {
try {
return await this.sftp.listFiles(path);
} catch (error) {
this.logger.error("Failed to list SFTP files", { error, path });
throw error;
}
}
// Helper methods
private parseCsvLine(line: string): string[] {
// Simple CSV parsing - handle commas within quotes
const result: string[] = [];
let current = "";
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
private parseDate(dateStr: string): Date | null {
if (!dateStr || dateStr.length < 8) return null;
// Clean the string
const clean = dateStr.replace(/[^0-9]/g, "");
if (clean.length < 8) return null;
const year = parseInt(clean.substring(0, 4), 10);
const month = parseInt(clean.substring(4, 6), 10) - 1;
const day = parseInt(clean.substring(6, 8), 10);
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
return new Date(year, month, day);
}
private formatTime(timeStr: string): string {
// Convert HHMMSS to HH:MM:SS
if (!timeStr || timeStr.length < 6) return timeStr;
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`;
}
private formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
private formatPhoneNumber(phone: string): string {
// Format Japanese phone numbers
if (!phone) return phone;
const clean = phone.replace(/[^0-9+]/g, "");
// 080-XXXX-XXXX or 070-XXXX-XXXX format
if (clean.length === 11 && (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))) {
return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`;
}
// 03-XXXX-XXXX format
if (clean.length === 10 && clean.startsWith("0")) {
return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`;
}
return clean;
}
private getDefaultMonth(): string {
// Default to 2 months ago (available data)
const now = new Date();
now.setMonth(now.getMonth() - 2);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
}

View File

@ -4,6 +4,7 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
import { EmailModule } from "@bff/infra/email/email.module";
import { SftpModule } from "@bff/integrations/sftp/sftp.module";
import { SimUsageStoreService } from "../sim-usage-store.service";
import { SubscriptionsService } from "../subscriptions.service";
@ -25,6 +26,7 @@ import { SimActionRunnerService } from "./services/sim-action-runner.service";
import { SimManagementQueueService } from "./queue/sim-management.queue";
import { SimManagementProcessor } from "./queue/sim-management.processor";
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
import { SimCallHistoryService } from "./services/sim-call-history.service";
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
@Module({
@ -35,6 +37,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
MappingsModule,
EmailModule,
CatalogModule,
SftpModule,
],
providers: [
// Core services that the SIM services depend on
@ -59,6 +62,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
SimActionRunnerService,
SimManagementQueueService,
SimManagementProcessor,
SimCallHistoryService,
// Export with token for optional injection in Freebit module
{
provide: "SimVoiceOptionsService",
@ -83,6 +87,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
SimActionRunnerService,
SimManagementQueueService,
SimVoiceOptionsService,
SimCallHistoryService,
"SimVoiceOptionsService", // Export the token
],
})

View File

@ -11,6 +11,7 @@ import {
UsePipes,
Header,
} from "@nestjs/common";
import { Public } from "@bff/modules/auth/decorators/public.decorator";
import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service";
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service";
@ -46,6 +47,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SimPlanService } from "./sim-management/services/sim-plan.service";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service";
import { EsimManagementService, type ReissueSimRequest } from "./sim-management/services/esim-management.service";
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service";
const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10,
@ -62,7 +64,8 @@ export class SubscriptionsController {
private readonly simTopUpPricingService: SimTopUpPricingService,
private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService
private readonly esimManagementService: EsimManagementService,
private readonly simCallHistoryService: SimCallHistoryService
) {}
@Get()
@ -88,25 +91,47 @@ export class SubscriptionsController {
return this.subscriptionsService.getSubscriptionStats(req.user.id);
}
@Get(":id")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
async getSubscriptionById(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
): Promise<Subscription> {
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
}
@Get(":id/invoices")
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
async getSubscriptionInvoices(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
): Promise<InvoiceList> {
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
// ==================== Static SIM Routes (must be before :id routes) ====================
/**
* Get available months for call/SMS history
*/
@Public()
@Get("sim/call-history/available-months")
@Header("Cache-Control", "public, max-age=3600")
async getAvailableMonths() {
const months = await this.simCallHistoryService.getAvailableMonths();
return { success: true, data: months };
}
// ==================== SIM Management Endpoints ====================
/**
* List available files on SFTP for debugging
*/
@Public()
@Get("sim/call-history/sftp-files")
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
const files = await this.simCallHistoryService.listSftpFiles(path);
return { success: true, data: files, path };
}
/**
* Trigger manual import of call history (admin only)
* TODO: Add proper admin authentication before production
*/
@Public()
@Post("sim/call-history/import")
async importCallHistory(@Query("month") yearMonth: string) {
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
throw new BadRequestException("Invalid month format (expected YYYYMM)");
}
const result = await this.simCallHistoryService.importCallHistory(yearMonth);
return {
success: true,
message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`,
data: result,
};
}
@Get("sim/top-up/pricing")
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing is relatively static
@ -132,6 +157,29 @@ export class SubscriptionsController {
return await this.simManagementService.getSimDetailsDebug(account);
}
// ==================== Dynamic :id Routes ====================
@Get(":id")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
async getSubscriptionById(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
): Promise<Subscription> {
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
}
@Get(":id/invoices")
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
async getSubscriptionInvoices(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
): Promise<InvoiceList> {
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
}
// ==================== SIM Management Endpoints (subscription-specific) ====================
@Get(":id/sim/debug")
async debugSimSubscription(
@Request() req: RequestWithUser,
@ -322,4 +370,102 @@ export class SubscriptionsController {
return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." };
}
}
// ==================== Call/SMS History Endpoints ====================
/**
* Get domestic call history
*/
@Get(":id/sim/call-history/domestic")
@Header("Cache-Control", "private, max-age=300")
async getDomesticCallHistory(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);
if (isNaN(pageNum) || pageNum < 1) {
throw new BadRequestException("Invalid page number");
}
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
throw new BadRequestException("Invalid limit (must be 1-100)");
}
const result = await this.simCallHistoryService.getDomesticCallHistory(
req.user.id,
subscriptionId,
month,
pageNum,
limitNum
);
return { success: true, data: result };
}
/**
* Get international call history
*/
@Get(":id/sim/call-history/international")
@Header("Cache-Control", "private, max-age=300")
async getInternationalCallHistory(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);
if (isNaN(pageNum) || pageNum < 1) {
throw new BadRequestException("Invalid page number");
}
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
throw new BadRequestException("Invalid limit (must be 1-100)");
}
const result = await this.simCallHistoryService.getInternationalCallHistory(
req.user.id,
subscriptionId,
month,
pageNum,
limitNum
);
return { success: true, data: result };
}
/**
* Get SMS history
*/
@Get(":id/sim/sms-history")
@Header("Cache-Control", "private, max-age=300")
async getSmsHistory(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string,
@Query("page") page?: string,
@Query("limit") limit?: string
) {
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "50", 10);
if (isNaN(pageNum) || pageNum < 1) {
throw new BadRequestException("Invalid page number");
}
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
throw new BadRequestException("Invalid limit (must be 1-100)");
}
const result = await this.simCallHistoryService.getSmsHistory(
req.user.id,
subscriptionId,
month,
pageNum,
limitNum
);
return { success: true, data: result };
}
}

View File

@ -0,0 +1,6 @@
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
export default function SimCallHistoryPage() {
return <SimCallHistoryContainer />;
}

View File

@ -6,7 +6,7 @@ import {
DevicePhoneMobileIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
SignalIcon,
PhoneIcon,
ArrowsRightLeftIcon,
ArrowPathRoundedSquareIcon,
XCircleIcon,
@ -60,6 +60,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
const navigateToCallHistory = () => router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
// Fetch subscription data
const { data: subscription } = useSubscription(subscriptionId);
@ -289,11 +290,11 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
<div className="grid grid-cols-2 gap-4">
<button
onClick={navigateToTopUp}
onClick={navigateToCallHistory}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
>
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Data Top up</span>
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Call History</span>
</button>
<button

View File

@ -125,4 +125,127 @@ export const simActionsService = {
body: request,
});
},
// Call/SMS History
async getDomesticCallHistory(
subscriptionId: string,
month?: string,
page: number = 1,
limit: number = 50
): Promise<DomesticCallHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{ success: boolean; data: DomesticCallHistoryResponse }>(
"/api/subscriptions/{subscriptionId}/sim/call-history/domestic",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data || null;
},
async getInternationalCallHistory(
subscriptionId: string,
month?: string,
page: number = 1,
limit: number = 50
): Promise<InternationalCallHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{ success: boolean; data: InternationalCallHistoryResponse }>(
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data || null;
},
async getSmsHistory(
subscriptionId: string,
month?: string,
page: number = 1,
limit: number = 50
): Promise<SmsHistoryResponse | null> {
const params: Record<string, string> = {};
if (month) params.month = month;
params.page = String(page);
params.limit = String(limit);
const response = await apiClient.GET<{ success: boolean; data: SmsHistoryResponse }>(
"/api/subscriptions/{subscriptionId}/sim/sms-history",
{
params: { path: { subscriptionId }, query: params },
}
);
return response.data?.data || null;
},
async getAvailableHistoryMonths(): Promise<string[]> {
const response = await apiClient.GET<{ success: boolean; data: string[] }>(
"/api/subscriptions/sim/call-history/available-months",
{}
);
return response.data?.data || [];
},
};
// Additional types for call/SMS history
export interface CallHistoryPagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface DomesticCallRecord {
id: string;
date: string;
time: string;
calledTo: string;
callLength: string;
callCharge: number;
}
export interface DomesticCallHistoryResponse {
calls: DomesticCallRecord[];
pagination: CallHistoryPagination;
month: string;
}
export interface InternationalCallRecord {
id: string;
date: string;
startTime: string;
stopTime: string | null;
country: string | null;
calledTo: string;
callCharge: number;
}
export interface InternationalCallHistoryResponse {
calls: InternationalCallRecord[];
pagination: CallHistoryPagination;
month: string;
}
export interface SmsRecord {
id: string;
date: string;
time: string;
sentTo: string;
type: string;
}
export interface SmsHistoryResponse {
messages: SmsRecord[];
pagination: CallHistoryPagination;
month: string;
}

View File

@ -0,0 +1,434 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { PhoneIcon, GlobeAltIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
import {
simActionsService,
type DomesticCallHistoryResponse,
type InternationalCallHistoryResponse,
type SmsHistoryResponse,
} from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency } = Formatting;
type TabType = "domestic" | "international" | "sms";
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-2 mt-4">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
);
}
export function SimCallHistoryContainer() {
const params = useParams();
const subscriptionId = params.id as string;
const [activeTab, setActiveTab] = useState<TabType>("domestic");
// Use September 2025 as the current month (latest available - 2 months behind)
const currentMonth = "2025-09";
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Data states
const [domesticData, setDomesticData] = useState<DomesticCallHistoryResponse | null>(null);
const [internationalData, setInternationalData] = useState<InternationalCallHistoryResponse | null>(null);
const [smsData, setSmsData] = useState<SmsHistoryResponse | null>(null);
// Pagination states
const [domesticPage, setDomesticPage] = useState(1);
const [internationalPage, setInternationalPage] = useState(1);
const [smsPage, setSmsPage] = useState(1);
// Fetch data when tab changes
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
if (activeTab === "domestic") {
const data = await simActionsService.getDomesticCallHistory(
subscriptionId,
currentMonth,
domesticPage,
50
);
setDomesticData(data);
} else if (activeTab === "international") {
const data = await simActionsService.getInternationalCallHistory(
subscriptionId,
currentMonth,
internationalPage,
50
);
setInternationalData(data);
} else if (activeTab === "sms") {
const data = await simActionsService.getSmsHistory(
subscriptionId,
currentMonth,
smsPage,
50
);
setSmsData(data);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load history");
} finally {
setLoading(false);
}
};
void fetchData();
}, [subscriptionId, activeTab, domesticPage, internationalPage, smsPage]);
// Reset page when tab changes
useEffect(() => {
if (activeTab === "domestic") setDomesticPage(1);
if (activeTab === "international") setInternationalPage(1);
if (activeTab === "sms") setSmsPage(1);
}, [activeTab]);
return (
<PageLayout
icon={<PhoneIcon />}
title="Call & SMS History"
description="View your call and SMS records"
>
<div className="max-w-5xl mx-auto">
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
</div>
<SubCard>
{/* Current Month Display */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-blue-900">
Showing data for: September 2025
</span>
</div>
<p className="text-xs text-blue-700 mt-1">
Call/SMS history is available approximately 2 months after the calls are made.
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex -mb-px space-x-8">
<button
onClick={() => setActiveTab("domestic")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "domestic"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<PhoneIcon className="h-5 w-5" />
Domestic Calls
{domesticData && (
<span className="ml-1 text-xs text-gray-400">
({domesticData.pagination.total})
</span>
)}
</button>
<button
onClick={() => setActiveTab("international")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "international"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<GlobeAltIcon className="h-5 w-5" />
International Calls
{internationalData && (
<span className="ml-1 text-xs text-gray-400">
({internationalData.pagination.total})
</span>
)}
</button>
<button
onClick={() => setActiveTab("sms")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "sms"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<ChatBubbleLeftIcon className="h-5 w-5" />
SMS History
{smsData && (
<span className="ml-1 text-xs text-gray-400">
({smsData.pagination.total})
</span>
)}
</button>
</nav>
</div>
{/* Error */}
{error && (
<div className="mb-4">
<AlertBanner variant="error" title="Error">
{error}
</AlertBanner>
</div>
)}
{/* Loading */}
{loading && (
<div className="animate-pulse space-y-2">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-12 bg-gray-100 rounded"></div>
))}
</div>
)}
{/* Domestic Calls Table */}
{!loading && activeTab === "domestic" && domesticData && (
<>
{domesticData.calls.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No domestic calls found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Called To
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Call Length
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Call Charge (¥)
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{domesticData.calls.map(call => (
<tr key={call.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{call.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{call.time}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{call.calledTo}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{call.callLength}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
{formatCurrency(call.callCharge)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={domesticData.pagination.page}
totalPages={domesticData.pagination.totalPages}
onPageChange={setDomesticPage}
/>
</>
)}
{/* International Calls Table */}
{!loading && activeTab === "international" && internationalData && (
<>
{internationalData.calls.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No international calls found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Start Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stop Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Country
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Called To
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Call Charge (¥)
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{internationalData.calls.map(call => (
<tr key={call.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{call.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{call.startTime}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{call.stopTime || "—"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{call.country || "—"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{call.calledTo}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
{formatCurrency(call.callCharge)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={internationalData.pagination.page}
totalPages={internationalData.pagination.totalPages}
onPageChange={setInternationalPage}
/>
</>
)}
{/* SMS Table */}
{!loading && activeTab === "sms" && smsData && (
<>
{smsData.messages.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No SMS found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sent To
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{smsData.messages.map(msg => (
<tr key={msg.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{msg.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{msg.time}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{msg.sentTo}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
msg.type === "International SMS"
? "bg-purple-100 text-purple-800"
: "bg-blue-100 text-blue-800"
}`}
>
{msg.type}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={smsData.pagination.page}
totalPages={smsData.pagination.totalPages}
onPageChange={setSmsPage}
/>
</>
)}
{/* Info Note */}
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 mb-2">Important Notes</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> Call/SMS history is updated approximately 2 months after the calls/messages are made</li>
<li> The history shows approximately 3 months of records</li>
<li> Call charges shown are based on the carrier billing data</li>
</ul>
</div>
</SubCard>
</div>
</PageLayout>
);
}
export default SimCallHistoryContainer;