Update security hardening and prune test/auth tooling

- Remove Passport-based auth; use jose-only guards
- Remove Jest/Istanbul toolchain and switch to node --test
- Stop runtime prisma dlx downloads; run migrations via bundled prisma
- Remove glob override and tighten Next.js config
This commit is contained in:
barsa 2025-12-12 11:47:17 +09:00
parent 8b1b402814
commit 5981ed941e
32 changed files with 6418 additions and 6056 deletions

View File

@ -1,10 +1,7 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
pnpm type-check
echo "Running security audit..."
if ! pnpm audit --audit-level=high > /dev/null 2>&1; then
echo "High or critical security vulnerabilities detected!"
echo "Run 'pnpm audit' to see details."
fi
# Security audit is enforced in CI (`.github/workflows/security.yml`).

View File

@ -8,7 +8,6 @@
ARG NODE_VERSION=22
ARG PNPM_VERSION=10.25.0
ARG PRISMA_VERSION=7.1.0
# =============================================================================
# Stage 1: Dependencies (cached layer)
@ -40,8 +39,6 @@ RUN --mount=type=cache,id=pnpm-bff,target=/root/.local/share/pnpm/store \
# =============================================================================
FROM deps AS builder
ARG PRISMA_VERSION
# Copy source files
COPY tsconfig.json tsconfig.base.json ./
COPY packages/domain/ ./packages/domain/
@ -62,7 +59,7 @@ RUN pnpm deploy --filter @customer-portal/bff --prod /app/deploy \
# Regenerate Prisma client in the flattened deploy layout so embedded schema path matches production
WORKDIR /app/deploy
RUN pnpm dlx prisma@${PRISMA_VERSION} generate --schema=prisma/schema.prisma
RUN pnpm exec prisma generate --schema=prisma/schema.prisma
WORKDIR /app
# =============================================================================
@ -71,7 +68,6 @@ WORKDIR /app
FROM node:${NODE_VERSION}-alpine AS production
ARG PNPM_VERSION
ARG PRISMA_VERSION
LABEL org.opencontainers.image.title="Customer Portal BFF" \
org.opencontainers.image.description="NestJS Backend-for-Frontend API" \
@ -109,7 +105,6 @@ EXPOSE 4000
# Environment configuration
ENV NODE_ENV=production \
PORT=4000 \
PRISMA_VERSION=${PRISMA_VERSION} \
# Node.js production optimizations
NODE_OPTIONS="--max-old-space-size=512"

View File

@ -16,11 +16,7 @@
"start:prod": "node dist/main.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
"test:cov": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
"test:debug": "NODE_OPTIONS='--experimental-vm-modules --inspect-brk' node node_modules/.bin/jest --runInBand",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json",
"test": "node --test",
"type-check": "tsc --project tsconfig.json --noEmit",
"type-check:watch": "tsc --project tsconfig.json --noEmit --watch",
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
@ -36,7 +32,6 @@
"@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.9",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
@ -51,10 +46,8 @@
"nestjs-pino": "^4.5.0",
"nestjs-zod": "^5.0.1",
"p-queue": "^9.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
"prisma": "^7.1.0",
"rate-limiter-flexible": "^9.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
@ -68,37 +61,10 @@
"@nestjs/testing": "^11.1.9",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.16.0",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/supertest": "^6.0.3",
"jest": "^30.2.0",
"pino-pretty": "^13.1.3",
"prisma": "^7.1.0",
"supertest": "^7.1.4",
"ts-jest": "^29.4.6",
"tsc-alias": "^1.8.16",
"typescript": "5.9.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"maxWorkers": "50%",
"extensionsToTreatAsEsm": [".ts"],
"transform": {
"^.+\\.ts$": ["ts-jest", { "useESM": true }]
},
"collectCoverageFrom": ["**/*.ts", "!**/*.spec.ts", "!**/node_modules/**"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^@/(.*)$": "<rootDir>/$1",
"^@bff/(.*)$": "<rootDir>/$1"
},
"passWithNoTests": true
}
}

View File

@ -14,7 +14,6 @@ echo "🚀 Starting Customer Portal Backend..."
echo " Version: ${APP_VERSION:-unknown}"
echo " Node: $(node --version)"
PRISMA_VERSION="${PRISMA_VERSION:-7.1.0}"
export PRISMA_SCHEMA_PATH="/app/prisma/schema.prisma"
# =============================================================================
@ -95,7 +94,7 @@ if [ "$RUN_MIGRATIONS" = "true" ] && [ -n "$DATABASE_URL" ]; then
# Change to app directory where schema is located
cd /app
if pnpm dlx prisma@"${PRISMA_VERSION}" migrate deploy; then
if pnpm exec prisma migrate deploy --schema=prisma/schema.prisma; then
echo "✅ Migrations complete"
else
echo "⚠️ Migration failed - check database connectivity"

View File

@ -1,71 +0,0 @@
import type { Response } from "express";
import type { Logger } from "nestjs-pino";
import { CsrfController } from "./csrf.controller.js";
import type { AuthenticatedRequest } from "./csrf.controller.js";
import type { CsrfService, CsrfTokenData } from "../services/csrf.service.js";
const createMockResponse = () => {
const cookie = jest.fn();
const json = jest.fn();
return {
cookie,
json,
} as unknown as Response;
};
describe("CsrfController", () => {
const csrfToken: CsrfTokenData = {
token: "token-value",
secret: "secret-value",
expiresAt: new Date(),
};
const makeController = (overrides: Partial<CsrfService> = {}) => {
const csrfService: Partial<CsrfService> = {
generateToken: jest.fn().mockReturnValue(csrfToken),
invalidateUserTokens: jest.fn(),
...overrides,
};
const logger = { debug: jest.fn() } as unknown as Logger;
return {
controller: new CsrfController(csrfService as CsrfService, logger),
csrfService,
};
};
it("passes session and user identifiers to generateToken in the correct argument order", () => {
const { controller, csrfService } = makeController();
const res = createMockResponse();
const req = {
user: { id: "user-123", sessionId: "session-456" },
cookies: {},
get: jest.fn(),
ip: "127.0.0.1",
} as unknown as AuthenticatedRequest;
controller.getCsrfToken(req, res);
expect(csrfService.generateToken).toHaveBeenCalledWith(undefined, "session-456", "user-123");
});
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {
const { controller, csrfService } = makeController();
const res = createMockResponse();
const req = {
cookies: { "connect.sid": "cookie-session" },
get: jest.fn(),
ip: "127.0.0.1",
} as unknown as AuthenticatedRequest;
controller.refreshCsrfToken(req, res);
expect(csrfService.generateToken).toHaveBeenCalledWith(
undefined,
"cookie-session",
"anonymous"
);
});
});

View File

@ -1,92 +0,0 @@
import type { Logger } from "nestjs-pino";
import type { ConfigService } from "@nestjs/config";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import type { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
describe("SalesforceConnection", () => {
const createService = () => {
const configService = {
get: jest.fn(),
} as unknown as ConfigService;
const execute = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const executeHighPriority = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const requestQueue = {
execute,
executeHighPriority,
} as unknown as SalesforceRequestQueueService;
const logger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
log: jest.fn(),
} as unknown as Logger;
const service = new SalesforceConnection(configService, requestQueue, logger);
// Override internal connection with simple stubs
const queryMock = jest.fn().mockResolvedValue("query-result");
service["connection"] = {
query: queryMock,
sobject: jest.fn().mockReturnValue({
create: jest.fn().mockResolvedValue({ id: "001" }),
update: jest.fn().mockResolvedValue({}),
}),
} as unknown as (typeof service)["connection"];
jest
.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected")
.mockResolvedValue();
return {
service,
requestQueue,
queryMock,
execute,
executeHighPriority,
};
};
afterEach(() => {
jest.restoreAllMocks();
});
it("routes standard queries through the request queue with derived priority metadata", async () => {
const { service, execute, queryMock } = createService();
await service.query("SELECT Id FROM Account WHERE Id = '001'");
expect(execute).toHaveBeenCalledTimes(1);
const [, options] = execute.mock.calls[0];
expect(options).toMatchObject({
priority: 8,
isLongRunning: false,
label: "salesforce:query:account",
});
expect(queryMock).toHaveBeenCalledTimes(1);
});
it("routes SObject create operations through the high-priority queue", async () => {
const { service, executeHighPriority } = createService();
const sobject = service.sobject("Order");
await sobject.create({ Name: "Test" });
expect(executeHighPriority).toHaveBeenCalledTimes(1);
const [, options] = executeHighPriority.mock.calls[0];
expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" });
});
});

View File

@ -9,6 +9,14 @@ export interface SftpConfig {
port: number;
username: string;
password: string;
/**
* Optional SHA256 host key fingerprint for MITM protection.
* Set via env: SFTP_HOST_KEY_SHA256
*
* The value is compared against ssh2's `hostHash: "sha256"` output.
* If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored.
*/
hostKeySha256?: string;
}
@Injectable()
@ -26,6 +34,7 @@ export class SftpClientService implements OnModuleDestroy {
port: this.configService.get<number>("SFTP_PORT") || 22,
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
password: this.configService.get<string>("SFTP_PASSWORD") || "",
hostKeySha256: this.configService.get<string>("SFTP_HOST_KEY_SHA256") || undefined,
};
}
@ -43,11 +52,34 @@ export class SftpClientService implements OnModuleDestroy {
try {
this.logger.log(`Connecting to SFTP: ${config.host}:${config.port}`);
const expectedHostKey = config.hostKeySha256?.trim().replace(/^SHA256:/, "");
if (!expectedHostKey && this.configService.get("NODE_ENV") === "production") {
this.logger.warn(
"SFTP_HOST_KEY_SHA256 not configured; host key will not be verified (MITM risk)"
);
}
await this.client.connect({
host: config.host,
port: config.port,
username: config.username,
password: config.password,
...(expectedHostKey
? {
hostHash: "sha256",
hostVerifier: (hash: string) => {
const ok = hash === expectedHostKey;
if (!ok) {
this.logger.error(
{ host: config.host, expectedHostKey, actualHostKey: hash },
"SFTP host key fingerprint mismatch"
);
}
return ok;
},
}
: {}),
});
this.logger.log(`Connected to SFTP: ${config.host}`);
return this.client;

View File

@ -1,13 +1,10 @@
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { APP_GUARD } from "@nestjs/core";
import { AuthFacade } from "./application/auth.facade.js";
import { AuthController } from "./presentation/http/auth.controller.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { JwtStrategy } from "./presentation/strategies/jwt.strategy.js";
import { LocalStrategy } from "./presentation/strategies/local.strategy.js";
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
@ -22,19 +19,10 @@ import { LoginResultInterceptor } from "./presentation/http/interceptors/login-r
import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js";
@Module({
imports: [
PassportModule,
UsersModule,
MappingsModule,
IntegrationsModule,
EmailModule,
CacheModule,
],
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
controllers: [AuthController],
providers: [
AuthFacade,
JwtStrategy,
LocalStrategy,
TokenBlacklistService,
AuthTokenService,
JoseJwtService,

View File

@ -1,14 +1,16 @@
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { ExtractJwt } from "passport-jwt";
import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { UserAuth } from "@customer-portal/domain/customer";
type CookieValue = string | undefined;
type RequestBase = Omit<Request, "cookies" | "route">;
@ -21,28 +23,30 @@ type RequestWithRoute = RequestWithCookies & {
route?: { path?: string };
};
const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken();
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
const headerToken = headerExtractor(request);
if (headerToken) {
return headerToken;
}
const rawHeader = (request as unknown as { headers?: Record<string, unknown> }).headers?.[
"authorization"
];
const authHeader = typeof rawHeader === "string" ? rawHeader : undefined;
const headerToken =
authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
if (headerToken && headerToken.length > 0) return headerToken;
const cookieToken = request.cookies?.access_token;
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
};
@Injectable()
export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
export class GlobalAuthGuard implements CanActivate {
private readonly logger = new Logger(GlobalAuthGuard.name);
constructor(
private reflector: Reflector,
private tokenBlacklistService: TokenBlacklistService
) {
super();
}
private readonly tokenBlacklistService: TokenBlacklistService,
private readonly jwtService: JoseJwtService,
private readonly usersFacade: UsersFacade
) {}
override async canActivate(context: ExecutionContext): Promise<boolean> {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = this.getRequest(context);
const route = `${request.method} ${request.url}`;
const isLogoutRoute = this.isLogoutRoute(request);
@ -59,28 +63,50 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
}
try {
// First, run the standard JWT authentication
const canActivate = await super.canActivate(context);
if (!canActivate) {
const token = extractTokenFromRequest(request);
if (!token) {
if (isLogoutRoute) {
this.logger.debug(`Allowing logout request without active session: ${route}`);
return true;
}
this.logger.warn(`JWT authentication failed for route: ${route}`);
return false;
throw new UnauthorizedException("Missing token");
}
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
token
);
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid token payload");
}
// Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") {
throw new UnauthorizedException("Token missing expiration claim");
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) {
throw new UnauthorizedException("Token expired or expiring soon");
}
// Then check token blacklist
const token = extractTokenFromRequest(request);
if (token) {
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
if (isBlacklisted) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
throw new UnauthorizedException("Token has been revoked");
}
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
if (isBlacklisted) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
throw new UnauthorizedException("Token has been revoked");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
}
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
this.logger.debug(`Authenticated access to: ${route}`);
return true;
} catch (error) {
@ -101,7 +127,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
throw error;
}
}
override getRequest(context: ExecutionContext): RequestWithRoute {
getRequest(context: ExecutionContext): RequestWithRoute {
const rawRequest = context.switchToHttp().getRequest<unknown>();
if (!this.isRequestWithRoute(rawRequest)) {
this.logger.error("Unable to determine HTTP request in auth guard");

View File

@ -1,5 +1,31 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}
export class LocalAuthGuard implements CanActivate {
constructor(private readonly authFacade: AuthFacade) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const body = (request.body ?? {}) as Record<string, unknown>;
const email = typeof body.email === "string" ? body.email : "";
const password = typeof body.password === "string" ? body.password : "";
if (!email || !password) {
throw new UnauthorizedException("Invalid credentials");
}
const user = await this.authFacade.validateUser(email, password, request);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
// Attach user to request (replaces Passport's req.user behavior)
(request as Request & { user: unknown }).user = user;
return true;
}
}

View File

@ -1,82 +0,0 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { Request } from "express";
const cookieExtractor = (req: Request): string | null => {
const cookieSource: unknown = Reflect.get(req, "cookies");
if (!cookieSource || typeof cookieSource !== "object") {
return null;
}
const token = Reflect.get(cookieSource, "access_token") as unknown;
return typeof token === "string" && token.length > 0 ? token : null;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private readonly usersFacade: UsersFacade
) {
const jwtSecret = configService.get<string>("JWT_SECRET");
if (!jwtSecret) {
throw new Error("JWT_SECRET is required in environment variables");
}
const options = {
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
cookieExtractor,
]),
ignoreExpiration: false,
secretOrKey: jwtSecret,
};
super(options);
}
async validate(payload: {
sub: string;
email: string;
role: string;
iat?: number;
exp?: number;
}): Promise<UserAuth> {
// Validate payload structure
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid JWT payload");
}
// Explicit expiry check with 60-second buffer to prevent edge cases
// where tokens expire during request processing
if (payload.exp) {
const nowSeconds = Math.floor(Date.now() / 1000);
const bufferSeconds = 60; // 1 minute buffer
if (payload.exp < nowSeconds + bufferSeconds) {
throw new UnauthorizedException("Token expired or expiring soon");
}
} else {
// Tokens without expiry are not allowed
throw new UnauthorizedException("Token missing expiration claim");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
}
const profile = mapPrismaUserToDomain(prismaUser);
return profile;
}
}

View File

@ -1,25 +0,0 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import type { Request } from "express";
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
import { ErrorCode, ErrorMessages } from "@customer-portal/domain/common";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authFacade: AuthFacade) {
super({ usernameField: "email", passReqToCallback: true });
}
async validate(
req: Request,
email: string,
password: string
): Promise<{ id: string; email: string; role: string }> {
const user = await this.authFacade.validateUser(email, password, req);
if (!user) {
throw new UnauthorizedException(ErrorMessages[ErrorCode.INVALID_CREDENTIALS]);
}
return user;
}
}

View File

@ -1,287 +0,0 @@
/// <reference types="jest" />
import { BadRequestException } from "@nestjs/common";
import type { Logger } from "nestjs-pino";
import { CheckoutService } from "./checkout.service.js";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import type { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
import type { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js";
const createLogger = (): Logger =>
({
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}) as unknown as Logger;
const internetPlan = {
id: "prod-1",
sku: "PLAN-1",
name: "Plan 1",
description: "Plan 1",
monthlyPrice: 1000,
oneTimePrice: 0,
} as unknown;
const simPlan = {
id: "sim-1",
sku: "SIM-PLAN-1",
name: "SIM Plan",
description: "SIM Plan",
monthlyPrice: 4500,
oneTimePrice: 0,
} as unknown;
const defaultActivationFee = {
id: "act-1",
sku: "SIM-ACTIVATION-FEE",
name: "SIM Activation Fee",
description: "One-time fee",
monthlyPrice: 0,
oneTimePrice: 3000,
catalogMetadata: {
autoAdd: true,
isDefault: true,
},
} as unknown;
const alternateActivationFee = {
id: "act-2",
sku: "SIM-ACTIVATION-PREMIUM",
name: "SIM Premium Activation",
description: "Premium activation",
monthlyPrice: 0,
oneTimePrice: 5000,
catalogMetadata: {
autoAdd: false,
isDefault: false,
},
} as unknown;
const createService = ({
internet,
sim,
vpn,
}: {
internet: Partial<InternetCatalogService>;
sim: Partial<SimCatalogService>;
vpn: Partial<VpnCatalogService>;
}) =>
new CheckoutService(
createLogger(),
internet as InternetCatalogService,
sim as SimCatalogService,
vpn as VpnCatalogService
);
describe("CheckoutService - personalized carts", () => {
it("uses personalized internet plans when userId is provided", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn().mockResolvedValue([internetPlan]),
getPlans: jest.fn(),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" }, undefined, "user-123");
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
expect(internetCatalogService.getPlans).not.toHaveBeenCalled();
});
it("rejects plans that are not available to the user", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn().mockResolvedValue([]),
getPlans: jest.fn(),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await expect(
service.buildCart(ORDER_TYPE.INTERNET, { planSku: "UNKNOWN" }, undefined, "user-123")
).rejects.toThrow(BadRequestException);
});
it("falls back to shared catalog when userId is not provided", async () => {
const internetCatalogService = {
getPlansForUser: jest.fn(),
getPlans: jest.fn().mockResolvedValue([internetPlan]),
getInstallations: jest.fn().mockResolvedValue([]),
getAddons: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: {
getPlans: jest.fn(),
getPlansForUser: jest.fn(),
getAddons: jest.fn(),
getActivationFees: jest.fn(),
},
vpn: {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
},
});
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" });
expect(internetCatalogService.getPlans).toHaveBeenCalledTimes(1);
expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled();
});
});
describe("CheckoutService - SIM activation fees", () => {
const internetCatalogService = {
getPlansForUser: jest.fn(),
getPlans: jest.fn(),
getInstallations: jest.fn(),
getAddons: jest.fn(),
};
const vpnCatalogService = {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
};
it("auto-adds default activation fee when none specified", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest.fn().mockResolvedValue([defaultActivationFee]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ sku: "SIM-PLAN-1" }),
expect.objectContaining({
sku: "SIM-ACTIVATION-FEE",
itemType: "activation",
autoAdded: true,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(3000);
});
it("respects explicit activation fee selection", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest
.fn()
.mockResolvedValue([defaultActivationFee, alternateActivationFee]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, {
planSku: "SIM-PLAN-1",
activationFeeSku: "SIM-ACTIVATION-PREMIUM",
});
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
sku: "SIM-ACTIVATION-PREMIUM",
autoAdded: false,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(5000);
});
it("throws when no activation fee is available", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
await expect(service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" })).rejects.toThrow(
"SIM activation fee is not available"
);
});
it("skips activation fees without SKUs and falls back to the next valid option", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest
.fn()
.mockResolvedValue([
{ ...(defaultActivationFee as Record<string, unknown>), sku: "" },
alternateActivationFee,
]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
sku: "SIM-ACTIVATION-PREMIUM",
itemType: "activation",
autoAdded: true,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(5000);
});
});

View File

@ -1,105 +0,0 @@
/// <reference types="jest" />
import { NotFoundException } from "@nestjs/common";
import type { Logger } from "nestjs-pino";
import { OrderOrchestrator } from "./order-orchestrator.service.js";
import type { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
import type { OrderValidator } from "./order-validator.service.js";
import type { OrderBuilder } from "./order-builder.service.js";
import type { OrderItemBuilder } from "./order-item-builder.service.js";
import type { OrdersCacheService } from "./orders-cache.service.js";
import type { OrderDetails } from "@customer-portal/domain/orders";
const buildLogger = (): Logger =>
({
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}) as unknown as Logger;
const createOrderDetails = (overrides: Partial<OrderDetails> = {}): OrderDetails => ({
id: "006000000000000AAA",
orderNumber: "O-123",
status: "Open",
effectiveDate: new Date().toISOString(),
totalAmount: 100,
createdDate: new Date().toISOString(),
lastModifiedDate: new Date().toISOString(),
activationStatus: "Pending",
itemsSummary: [],
items: [],
accountId: "001000000000000AAA",
...overrides,
});
describe("OrderOrchestrator.getOrderForUser", () => {
const logger = buildLogger();
const salesforce = {} as SalesforceOrderService;
const orderValidator = {
validateUserMapping: jest.fn(),
} as unknown as OrderValidator;
const orderBuilder = {} as OrderBuilder;
const orderItemBuilder = {} as OrderItemBuilder;
const ordersCache = {} as OrdersCacheService;
const buildOrchestrator = () =>
new OrderOrchestrator(
logger,
salesforce,
orderValidator,
orderBuilder,
orderItemBuilder,
ordersCache
);
beforeEach(() => {
jest.resetAllMocks();
});
it("returns the order when the Salesforce account matches the user mapping", async () => {
const orchestrator = buildOrchestrator();
const expectedOrder = createOrderDetails();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
sfAccountId: expectedOrder.accountId,
whmcsClientId: 42,
});
const getOrderSpy = jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
expect(result).toBe(expectedOrder);
expect(getOrderSpy).toHaveBeenCalledWith(expectedOrder.id);
});
it("throws NotFound when the user mapping lacks a Salesforce account", async () => {
const orchestrator = buildOrchestrator();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
whmcsClientId: 42,
});
const getOrderSpy = jest.spyOn(orchestrator, "getOrder");
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
NotFoundException
);
expect(getOrderSpy).not.toHaveBeenCalled();
});
it("throws NotFound when the order belongs to a different account", async () => {
const orchestrator = buildOrchestrator();
orderValidator.validateUserMapping = jest.fn().mockResolvedValue({
userId: "user-1",
sfAccountId: "001000000000000AAA",
whmcsClientId: 42,
});
jest
.spyOn(orchestrator, "getOrder")
.mockResolvedValue(createOrderDetails({ accountId: "001000000000999ZZZ" }));
await expect(orchestrator.getOrderForUser("006000000000000AAA", "user-1")).rejects.toThrow(
NotFoundException
);
});
});

View File

@ -13,8 +13,8 @@
"@bff/*": ["src/*"]
},
"noEmit": true,
"types": ["node", "jest"]
"types": ["node"]
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist", "prisma"]
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -29,10 +29,6 @@ const nextConfig = {
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
},
images: {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},

View File

@ -4,7 +4,6 @@
"use client";
import { useCallback } from "react";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks";
@ -33,7 +32,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
return (
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
<FormField
label="Email Address"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
@ -46,7 +49,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
/>
</FormField>
<FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}

View File

@ -13,7 +13,7 @@ function formatFieldName(field: string): string {
return field
.replace("address.", "")
.replace(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.replace(/^./, s => s.toUpperCase())
.trim();
}
@ -89,7 +89,7 @@ interface ReviewStepProps {
}
export function ReviewStep({ form }: ReviewStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
const { values, errors, setValue, setTouchedField } = form;
const address = values.address;
// Format address for display
@ -130,7 +130,9 @@ export function ReviewStep({ form }: ReviewStepProps) {
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Phone</dt>
<dd className="text-gray-900 font-medium">{values.phoneCountryCode} {values.phone}</dd>
<dd className="text-gray-900 font-medium">
{values.phoneCountryCode} {values.phone}
</dd>
</div>
{values.company && (
<div className="flex justify-between py-2 border-b border-gray-100">
@ -162,7 +164,7 @@ export function ReviewStep({ form }: ReviewStepProps) {
<input
type="checkbox"
checked={values.acceptTerms}
onChange={(e) => setValue("acceptTerms", e.target.checked)}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
@ -194,14 +196,12 @@ export function ReviewStep({ form }: ReviewStepProps) {
<input
type="checkbox"
checked={values.marketingConsent ?? false}
onChange={(e) => setValue("marketingConsent", e.target.checked)}
onChange={e => setValue("marketingConsent", e.target.checked)}
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
Send me updates about new products and promotions
<span className="block text-xs text-gray-500 mt-0.5">
You can unsubscribe anytime
</span>
<span className="block text-xs text-gray-500 mt-0.5">You can unsubscribe anytime</span>
</span>
</label>
</div>

View File

@ -19,10 +19,7 @@ interface ServiceManagementSectionProps {
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
// Inner component that uses useSearchParams
function ServiceManagementContent({
subscriptionId,
productName,
}: ServiceManagementSectionProps) {
function ServiceManagementContent({ subscriptionId, productName }: ServiceManagementSectionProps) {
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
const [selectedService, setSelectedService] = useState<ServiceKey>(
@ -132,8 +129,6 @@ function ServiceManagementContent({
// Wrapper component with Suspense boundary
export function ServiceManagementSection(props: ServiceManagementSectionProps) {
const isSimService = useMemo(() => props.productName?.toLowerCase().includes("sim"), [props.productName]);
return (
<Suspense
fallback={

View File

@ -147,30 +147,6 @@ export function SimDetailsCard({
const usedGB = totalGB - remainingGB;
const usagePercentage = (usedGB / totalGB) * 100;
// Usage Sparkline Component
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
const width = 80;
const height = 16;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - (d.usedMB / maxValue) * height;
return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height} className="text-blue-500">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="1.5"
points={points}
/>
</svg>
);
};
// Usage Donut Component
const UsageDonut = ({ size = 120 }: { size?: number }) => {
const radius = (size - 16) / 2;
@ -250,7 +226,6 @@ export function SimDetailsCard({
))}
</div>
</div>
</div>
</div>
);
@ -366,9 +341,7 @@ export function SimDetailsCard({
{simDetails.internationalRoamingEnabled && (
<div className="flex items-center">
<WifiIcon className="h-4 w-4 mr-1 text-green-500" />
<span className="text-sm text-green-600">
International Roaming Enabled
</span>
<span className="text-sm text-green-600">International Roaming Enabled</span>
</div>
)}
</div>
@ -381,9 +354,7 @@ export function SimDetailsCard({
<div className="mt-6 pt-6 border-t border-gray-200">
<div className="flex items-center text-sm">
<ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
<span className="text-amber-800">
Expires on {formatDate(simDetails.expiresAt)}
</span>
<span className="text-amber-800">Expires on {formatDate(simDetails.expiresAt)}</span>
</div>
</div>
)}

View File

@ -3,7 +3,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
DevicePhoneMobileIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
PhoneIcon,
@ -57,23 +56,25 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
// Navigation handlers
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
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`);
const navigateToCallHistory = () =>
router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
// Fetch subscription data
const { data: subscription } = useSubscription(subscriptionId);
// Fetch latest invoice (limit 1)
const { data: invoicesData } = useSubscriptionInvoices(subscriptionId, { limit: 1 });
const latestInvoice = invoicesData?.invoices?.[0];
// SSO link mutation for payment
const createSsoLink = useCreateInvoiceSsoLink({
onSuccess: (data) => {
onSuccess: data => {
if (data.url) {
window.open(data.url, '_blank');
window.open(data.url, "_blank");
}
},
});
@ -86,15 +87,17 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
params: { path: { id: subscriptionId } },
});
const payload = response.data as { details: SimDetails; usage: any } | undefined;
const payload = response.data as
| { details: SimDetails; usage?: SimInfo["usage"] }
| undefined;
if (!payload) {
throw new Error("Failed to load SIM information");
}
setSimInfo({
setSimInfo({
details: payload.details,
usage: payload.usage
usage: payload.usage,
});
} catch (err: unknown) {
const hasStatus = (v: unknown): v is { status: number } =>
@ -121,13 +124,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
void fetchSimInfo();
};
const handleActionSuccess = () => {
void fetchSimInfo();
};
const handlePayInvoice = () => {
if (latestInvoice?.id) {
createSsoLink.mutate({ invoiceId: latestInvoice.id, target: 'pay' });
createSsoLink.mutate({ invoiceId: latestInvoice.id, target: "pay" });
}
};
@ -181,11 +180,11 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}
const remainingGB = (simInfo.details.remainingQuotaMb / 1000).toFixed(1);
const usedGB = simInfo.usage?.monthlyUsageMb
const usedGB = simInfo.usage?.monthlyUsageMb
? (simInfo.usage.monthlyUsageMb / 1000).toFixed(2)
: simInfo.usage?.todayUsageMb
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
: "0.00";
: simInfo.usage?.todayUsageMb
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
: "0.00";
// Calculate percentage for circle
const totalGB = parseFloat(remainingGB) + parseFloat(usedGB);
@ -203,26 +202,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">
Monthly Cost: <span className="font-semibold text-gray-900">
Monthly Cost:{" "}
<span className="font-semibold text-gray-900">
{subscription ? formatCurrency(subscription.amount) : "Loading..."}
</span>
</p>
</div>
<div>
<p className="text-sm text-gray-600">
Next Billing: <span className="font-semibold text-gray-900">
Next Billing:{" "}
<span className="font-semibold text-gray-900">
{subscription?.nextDue ? formatDate(subscription.nextDue) : "N/A"}
</span>
</p>
</div>
<div>
<p className="text-sm text-gray-600">
Registered: <span className="font-semibold text-gray-900">
{subscription?.registrationDate ? formatDate(subscription.registrationDate) : "N/A"}
Registered:{" "}
<span className="font-semibold text-gray-900">
{subscription?.registrationDate
? formatDate(subscription.registrationDate)
: "N/A"}
</span>
</p>
</div>
{/* Latest Invoice Section - Mobile View */}
{latestInvoice && (
<div className="lg:hidden mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
@ -232,14 +236,18 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</p>
<button
onClick={handlePayInvoice}
disabled={createSsoLink.isPending || latestInvoice.status === 'Paid'}
disabled={createSsoLink.isPending || latestInvoice.status === "Paid"}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{latestInvoice.status === 'Paid' ? 'PAID' : createSsoLink.isPending ? 'Loading...' : 'PAY'}
{latestInvoice.status === "Paid"
? "PAID"
: createSsoLink.isPending
? "Loading..."
: "PAY"}
</button>
</div>
)}
{/* Top Up Data Button */}
<div className="pt-4">
<button
@ -255,14 +263,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="96"
cy="96"
r="88"
fill="none"
stroke="#e5e7eb"
strokeWidth="12"
/>
<circle cx="96" cy="96" r="88" fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="96"
cy="96"
@ -296,7 +297,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<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
onClick={navigateToChangePlan}
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"
@ -304,7 +305,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Change Plan</span>
</button>
<button
onClick={navigateToReissue}
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"
@ -312,7 +313,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
</button>
<button
onClick={navigateToCancel}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-red-500 hover:shadow-md transition-all duration-200"
@ -356,7 +357,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div>
<p className="font-medium text-gray-900">Network Type</p>
<p className="text-xs text-gray-600">
{simInfo.details.networkType ? simInfo.details.networkType : 'Disabled'}
{simInfo.details.networkType ? simInfo.details.networkType : "Disabled"}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
@ -421,11 +422,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
<span>
Voice, network, and plan changes must be requested at least 30 minutes apart.
</span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</span>
<span>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
</span>
</li>
</ul>
</div>

View File

@ -1,10 +1,14 @@
import { apiClient } from "@/lib/api";
import { simInfoSchema, type SimInfo, type SimCancelFullRequest, type SimChangePlanFullRequest } from "@customer-portal/domain/sim";
import {
simInfoSchema,
type SimInfo,
type SimCancelFullRequest,
type SimChangePlanFullRequest,
} from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
SimCancelRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim";
// Types imported from domain - no duplication
@ -63,14 +67,18 @@ export const simActionsService = {
});
},
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
{
params: { path: { subscriptionId } },
body: request,
}
);
async changePlanFull(
subscriptionId: string,
request: SimChangePlanFullRequest
): Promise<{ scheduledAt?: string }> {
const response = await apiClient.POST<{
success: boolean;
message: string;
scheduledAt?: string;
}>("/api/subscriptions/{subscriptionId}/sim/change-plan-full", {
params: { path: { subscriptionId } },
body: request,
});
return { scheduledAt: response.data?.scheduledAt };
},
@ -160,12 +168,12 @@ export const simActionsService = {
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 },
}
);
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;
},

View File

@ -6,12 +6,7 @@ import {
ChatBubbleLeftRightIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import {
SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY,
type SupportCaseStatus,
type SupportCasePriority,
} from "@customer-portal/domain/support";
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY } from "@customer-portal/domain/support";
/**
* Status variant types for styling
@ -33,13 +28,15 @@ const ICON_SIZE_CLASSES: Record<IconSize, string> = {
* Status to icon mapping
*/
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
[SUPPORT_CASE_STATUS.RESOLVED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.CLOSED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.VPN_PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => <ClockIcon className={`${cls} text-blue-500`} />,
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: (cls) => <ExclamationTriangleIcon className={`${cls} text-amber-500`} />,
[SUPPORT_CASE_STATUS.NEW]: (cls) => <SparklesIcon className={`${cls} text-purple-500`} />,
[SUPPORT_CASE_STATUS.RESOLVED]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.VPN_PENDING]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.PENDING]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.IN_PROGRESS]: cls => <ClockIcon className={`${cls} text-blue-500`} />,
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: cls => (
<ExclamationTriangleIcon className={`${cls} text-amber-500`} />
),
[SUPPORT_CASE_STATUS.NEW]: cls => <SparklesIcon className={`${cls} text-purple-500`} />,
};
/**
@ -112,11 +109,11 @@ const PRIORITY_CLASSES_WITH_BORDER: Record<CasePriorityVariant, string> = {
export function getCaseStatusIcon(status: string, size: IconSize = "sm"): ReactNode {
const sizeClass = ICON_SIZE_CLASSES[size];
const iconFn = STATUS_ICON_MAP[status];
if (iconFn) {
return iconFn(sizeClass);
}
return <ChatBubbleLeftRightIcon className={`${sizeClass} text-gray-400`} />;
}
@ -149,4 +146,3 @@ export function getCasePriorityClasses(priority: string, withBorder = false): st
const variant = getCasePriorityVariant(priority);
return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant];
}

View File

@ -1,15 +1,9 @@
"use client";
import {
CalendarIcon,
ClockIcon,
TagIcon,
ArrowLeftIcon,
} from "@heroicons/react/24/outline";
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
@ -77,16 +71,12 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
<div className="p-5 border-b border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getCaseStatusIcon(supportCase.status, "md")}
</div>
<div className="mt-0.5">{getCaseStatusIcon(supportCase.status, "md")}</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
{supportCase.subject}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
Case #{supportCase.caseNumber}
</p>
<p className="text-sm text-gray-500 mt-0.5">Case #{supportCase.caseNumber}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
@ -112,7 +102,10 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
</div>
<div className="flex items-center gap-2 text-gray-600">
<ClockIcon className="h-4 w-4 text-gray-400" />
<span>Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}</span>
<span>
Updated{" "}
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</span>
</div>
{supportCase.category && (
<div className="flex items-center gap-2 text-gray-600">
@ -145,12 +138,18 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="text-sm text-blue-700">
<p className="font-medium">Need to update this case?</p>
<p className="mt-0.5 text-blue-600">Reply via email and your response will be added to this case automatically.</p>
<p className="mt-0.5 text-blue-600">
Reply via email and your response will be added to this case automatically.
</p>
</div>
</div>
</div>

View File

@ -3,7 +3,6 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ChatBubbleLeftRightIcon,
SparklesIcon,
PlusIcon,
ChevronRightIcon,
@ -48,10 +47,7 @@ export function SupportHomeView() {
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
Get instant answers to common questions about your account.
</p>
<Button
size="sm"
className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm"
>
<Button size="sm" className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm">
Start Chat
</Button>
</div>
@ -69,12 +65,7 @@ export function SupportHomeView() {
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
Our team typically responds within 24 hours.
</p>
<Button
as="a"
href="/support/new"
size="sm"
variant="outline"
>
<Button as="a" href="/support/new" size="sm" variant="outline">
New Case
</Button>
</div>
@ -119,9 +110,7 @@ export function SupportHomeView() {
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
>
<div className="flex-shrink-0">
{getCaseStatusIcon(supportCase.status)}
</div>
<div className="flex-shrink-0">{getCaseStatusIcon(supportCase.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-gray-900">

View File

@ -248,4 +248,3 @@ export function useZodForm<TValues extends Record<string, unknown>>({
reset,
};
}

View File

@ -15,7 +15,7 @@ interface QueryProviderProps {
nonce?: string;
}
export function QueryProvider({ children, nonce }: QueryProviderProps) {
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({

View File

@ -108,6 +108,15 @@ FREEBIT_OEM_KEY=CHANGE_ME
# FREEBIT_TIMEOUT=30000 # default
# FREEBIT_RETRY_ATTEMPTS=3 # default
# --- SFTP (SIM Call/SMS history imports) ---
# SFTP_HOST=fs.mvno.net # default
# SFTP_PORT=22 # default
# SFTP_USERNAME=PASI # default
SFTP_PASSWORD=CHANGE_ME
# Optional but recommended (MITM protection): server host key fingerprint
# You can paste OpenSSH-style values (prefix "SHA256:" is ignored).
# SFTP_HOST_KEY_SHA256=CHANGE_ME
# --- SendGrid (Email) ---
SENDGRID_API_KEY=CHANGE_ME
# EMAIL_ENABLED=true # default

View File

@ -31,12 +31,28 @@ export default [
rules: { "prettier/prettier": "warn" },
},
// TypeScript type-checked rules for all TS files
...tseslint.configs.recommendedTypeChecked.map((config) => ({
// TypeScript recommended rules (fast, no type info)
...tseslint.configs.recommended.map((config) => ({
...config,
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
...(config.languageOptions || {}),
// Keep config simple: allow both environments; app-specific blocks can tighten later
globals: { ...globals.browser, ...globals.node },
},
})),
// TypeScript type-aware rules only where we really want them (backend + shared packages)
...tseslint.configs.recommendedTypeChecked.map((config) => ({
...config,
files: ["apps/bff/**/*.ts", "packages/**/*.ts"],
languageOptions: {
...(config.languageOptions || {}),
parserOptions: {
...((config.languageOptions && config.languageOptions.parserOptions) || {}),
projectService: true,
tsconfigRootDir: process.cwd(),
},
globals: { ...globals.node },
},
})),
@ -75,21 +91,26 @@ export default [
files: ["apps/portal/**/*.{js,jsx,ts,tsx}"],
},
{
...reactHooks.configs.flat.recommended,
// Keep this minimal (defaults-first): only the two classic hook rules.
files: ["apps/portal/**/*.{jsx,tsx}"],
plugins: { "react-hooks": reactHooks },
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
// Portal type-aware rules
// Portal overrides
{
files: ["apps/portal/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: process.cwd(),
},
globals: { ...globals.browser, ...globals.node },
},
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@next/next/no-html-link-for-pages": "off",
},
},

View File

@ -17,6 +17,7 @@
"test": "pnpm --recursive run test",
"lint": "pnpm --recursive run lint",
"lint:fix": "pnpm --recursive run lint:fix",
"lint-staged": "lint-staged",
"format": "prettier -w .",
"format:check": "prettier -c .",
"prepare": "husky",
@ -54,6 +55,7 @@
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.3",
"eslint": "^9.39.1",
"lint-staged": "^16.2.7",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
@ -66,7 +68,6 @@
"pnpm": {
"overrides": {
"js-yaml": ">=4.1.1",
"glob": "^8.1.0",
"typescript": "5.9.3",
"@types/node": "24.10.3",
"zod": "4.1.13"

11265
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff