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:
parent
8b1b402814
commit
5981ed941e
@ -1,10 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm lint-staged
|
||||||
pnpm type-check
|
pnpm type-check
|
||||||
|
|
||||||
echo "Running security audit..."
|
# Security audit is enforced in CI (`.github/workflows/security.yml`).
|
||||||
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
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
ARG NODE_VERSION=22
|
ARG NODE_VERSION=22
|
||||||
ARG PNPM_VERSION=10.25.0
|
ARG PNPM_VERSION=10.25.0
|
||||||
ARG PRISMA_VERSION=7.1.0
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 1: Dependencies (cached layer)
|
# 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
|
FROM deps AS builder
|
||||||
|
|
||||||
ARG PRISMA_VERSION
|
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY tsconfig.json tsconfig.base.json ./
|
COPY tsconfig.json tsconfig.base.json ./
|
||||||
COPY packages/domain/ ./packages/domain/
|
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
|
# Regenerate Prisma client in the flattened deploy layout so embedded schema path matches production
|
||||||
WORKDIR /app/deploy
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -71,7 +68,6 @@ WORKDIR /app
|
|||||||
FROM node:${NODE_VERSION}-alpine AS production
|
FROM node:${NODE_VERSION}-alpine AS production
|
||||||
|
|
||||||
ARG PNPM_VERSION
|
ARG PNPM_VERSION
|
||||||
ARG PRISMA_VERSION
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="Customer Portal BFF" \
|
LABEL org.opencontainers.image.title="Customer Portal BFF" \
|
||||||
org.opencontainers.image.description="NestJS Backend-for-Frontend API" \
|
org.opencontainers.image.description="NestJS Backend-for-Frontend API" \
|
||||||
@ -109,7 +105,6 @@ EXPOSE 4000
|
|||||||
# Environment configuration
|
# Environment configuration
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=4000 \
|
PORT=4000 \
|
||||||
PRISMA_VERSION=${PRISMA_VERSION} \
|
|
||||||
# Node.js production optimizations
|
# Node.js production optimizations
|
||||||
NODE_OPTIONS="--max-old-space-size=512"
|
NODE_OPTIONS="--max-old-space-size=512"
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,7 @@
|
|||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
"test": "node --test",
|
||||||
"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",
|
|
||||||
"type-check": "tsc --project tsconfig.json --noEmit",
|
"type-check": "tsc --project tsconfig.json --noEmit",
|
||||||
"type-check:watch": "tsc --project tsconfig.json --noEmit --watch",
|
"type-check:watch": "tsc --project tsconfig.json --noEmit --watch",
|
||||||
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
|
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
|
||||||
@ -36,7 +32,6 @@
|
|||||||
"@nestjs/common": "^11.1.9",
|
"@nestjs/common": "^11.1.9",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.9",
|
"@nestjs/core": "^11.1.9",
|
||||||
"@nestjs/passport": "^11.0.5",
|
|
||||||
"@nestjs/platform-express": "^11.1.9",
|
"@nestjs/platform-express": "^11.1.9",
|
||||||
"@prisma/adapter-pg": "^7.1.0",
|
"@prisma/adapter-pg": "^7.1.0",
|
||||||
"@prisma/client": "^7.1.0",
|
"@prisma/client": "^7.1.0",
|
||||||
@ -51,10 +46,8 @@
|
|||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
"nestjs-zod": "^5.0.1",
|
"nestjs-zod": "^5.0.1",
|
||||||
"p-queue": "^9.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",
|
"pg": "^8.16.3",
|
||||||
|
"prisma": "^7.1.0",
|
||||||
"rate-limiter-flexible": "^9.0.0",
|
"rate-limiter-flexible": "^9.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
@ -68,37 +61,10 @@
|
|||||||
"@nestjs/testing": "^11.1.9",
|
"@nestjs/testing": "^11.1.9",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@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/pg": "^8.16.0",
|
||||||
"@types/ssh2-sftp-client": "^9.0.6",
|
"@types/ssh2-sftp-client": "^9.0.6",
|
||||||
"@types/supertest": "^6.0.3",
|
|
||||||
"jest": "^30.2.0",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prisma": "^7.1.0",
|
|
||||||
"supertest": "^7.1.4",
|
|
||||||
"ts-jest": "^29.4.6",
|
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"typescript": "5.9.3"
|
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ echo "🚀 Starting Customer Portal Backend..."
|
|||||||
echo " Version: ${APP_VERSION:-unknown}"
|
echo " Version: ${APP_VERSION:-unknown}"
|
||||||
echo " Node: $(node --version)"
|
echo " Node: $(node --version)"
|
||||||
|
|
||||||
PRISMA_VERSION="${PRISMA_VERSION:-7.1.0}"
|
|
||||||
export PRISMA_SCHEMA_PATH="/app/prisma/schema.prisma"
|
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
|
# Change to app directory where schema is located
|
||||||
cd /app
|
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"
|
echo "✅ Migrations complete"
|
||||||
else
|
else
|
||||||
echo "⚠️ Migration failed - check database connectivity"
|
echo "⚠️ Migration failed - check database connectivity"
|
||||||
|
|||||||
@ -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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -9,6 +9,14 @@ export interface SftpConfig {
|
|||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
password: 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()
|
@Injectable()
|
||||||
@ -26,6 +34,7 @@ export class SftpClientService implements OnModuleDestroy {
|
|||||||
port: this.configService.get<number>("SFTP_PORT") || 22,
|
port: this.configService.get<number>("SFTP_PORT") || 22,
|
||||||
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
|
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
|
||||||
password: this.configService.get<string>("SFTP_PASSWORD") || "",
|
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 {
|
try {
|
||||||
this.logger.log(`Connecting to SFTP: ${config.host}:${config.port}`);
|
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({
|
await this.client.connect({
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
username: config.username,
|
username: config.username,
|
||||||
password: config.password,
|
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}`);
|
this.logger.log(`Connected to SFTP: ${config.host}`);
|
||||||
return this.client;
|
return this.client;
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
|
||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { AuthFacade } from "./application/auth.facade.js";
|
import { AuthFacade } from "./application/auth.facade.js";
|
||||||
import { AuthController } from "./presentation/http/auth.controller.js";
|
import { AuthController } from "./presentation/http/auth.controller.js";
|
||||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { IntegrationsModule } from "@bff/integrations/integrations.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 { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
|
||||||
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
||||||
import { EmailModule } from "@bff/infra/email/email.module.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";
|
import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
|
||||||
PassportModule,
|
|
||||||
UsersModule,
|
|
||||||
MappingsModule,
|
|
||||||
IntegrationsModule,
|
|
||||||
EmailModule,
|
|
||||||
CacheModule,
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthFacade,
|
AuthFacade,
|
||||||
JwtStrategy,
|
|
||||||
LocalStrategy,
|
|
||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
AuthTokenService,
|
AuthTokenService,
|
||||||
JoseJwtService,
|
JoseJwtService,
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
|
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common";
|
||||||
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
|
||||||
import { ExtractJwt } from "passport-jwt";
|
|
||||||
|
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
||||||
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
|
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.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 CookieValue = string | undefined;
|
||||||
type RequestBase = Omit<Request, "cookies" | "route">;
|
type RequestBase = Omit<Request, "cookies" | "route">;
|
||||||
@ -21,28 +23,30 @@ type RequestWithRoute = RequestWithCookies & {
|
|||||||
route?: { path?: string };
|
route?: { path?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken();
|
|
||||||
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
||||||
const headerToken = headerExtractor(request);
|
const rawHeader = (request as unknown as { headers?: Record<string, unknown> }).headers?.[
|
||||||
if (headerToken) {
|
"authorization"
|
||||||
return headerToken;
|
];
|
||||||
}
|
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;
|
const cookieToken = request.cookies?.access_token;
|
||||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
export class GlobalAuthGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(GlobalAuthGuard.name);
|
private readonly logger = new Logger(GlobalAuthGuard.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
private reflector: Reflector,
|
||||||
private tokenBlacklistService: TokenBlacklistService
|
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||||
) {
|
private readonly jwtService: JoseJwtService,
|
||||||
super();
|
private readonly usersFacade: UsersFacade
|
||||||
}
|
) {}
|
||||||
|
|
||||||
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = this.getRequest(context);
|
const request = this.getRequest(context);
|
||||||
const route = `${request.method} ${request.url}`;
|
const route = `${request.method} ${request.url}`;
|
||||||
const isLogoutRoute = this.isLogoutRoute(request);
|
const isLogoutRoute = this.isLogoutRoute(request);
|
||||||
@ -59,28 +63,50 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, run the standard JWT authentication
|
const token = extractTokenFromRequest(request);
|
||||||
const canActivate = await super.canActivate(context);
|
if (!token) {
|
||||||
if (!canActivate) {
|
|
||||||
if (isLogoutRoute) {
|
if (isLogoutRoute) {
|
||||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.logger.warn(`JWT authentication failed for route: ${route}`);
|
throw new UnauthorizedException("Missing token");
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
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
|
// Then check token blacklist
|
||||||
const token = extractTokenFromRequest(request);
|
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
||||||
|
if (isBlacklisted) {
|
||||||
if (token) {
|
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
|
||||||
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
throw new UnauthorizedException("Token has been revoked");
|
||||||
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}`);
|
this.logger.debug(`Authenticated access to: ${route}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -101,7 +127,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override getRequest(context: ExecutionContext): RequestWithRoute {
|
getRequest(context: ExecutionContext): RequestWithRoute {
|
||||||
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
||||||
if (!this.isRequestWithRoute(rawRequest)) {
|
if (!this.isRequestWithRoute(rawRequest)) {
|
||||||
this.logger.error("Unable to determine HTTP request in auth guard");
|
this.logger.error("Unable to determine HTTP request in auth guard");
|
||||||
|
|||||||
@ -1,5 +1,31 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
|
||||||
|
|
||||||
@Injectable()
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -13,8 +13,8 @@
|
|||||||
"@bff/*": ["src/*"]
|
"@bff/*": ["src/*"]
|
||||||
},
|
},
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "jest"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "test/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "prisma"]
|
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -29,10 +29,6 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||||
},
|
},
|
||||||
|
|
||||||
images: {
|
|
||||||
remotePatterns: [{ protocol: "https", hostname: "**" }],
|
|
||||||
},
|
|
||||||
|
|
||||||
compiler: {
|
compiler: {
|
||||||
removeConsole: process.env.NODE_ENV === "production",
|
removeConsole: process.env.NODE_ENV === "production",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||||
@ -33,7 +32,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
|
<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
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.values.email}
|
value={form.values.email}
|
||||||
@ -46,7 +49,11 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</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
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.values.password}
|
value={form.values.password}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ function formatFieldName(field: string): string {
|
|||||||
return field
|
return field
|
||||||
.replace("address.", "")
|
.replace("address.", "")
|
||||||
.replace(/([A-Z])/g, " $1")
|
.replace(/([A-Z])/g, " $1")
|
||||||
.replace(/^./, (s) => s.toUpperCase())
|
.replace(/^./, s => s.toUpperCase())
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ interface ReviewStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewStep({ form }: ReviewStepProps) {
|
export function ReviewStep({ form }: ReviewStepProps) {
|
||||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
const { values, errors, setValue, setTouchedField } = form;
|
||||||
const address = values.address;
|
const address = values.address;
|
||||||
|
|
||||||
// Format address for display
|
// Format address for display
|
||||||
@ -130,7 +130,9 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
<dt className="text-gray-500">Phone</dt>
|
<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>
|
</div>
|
||||||
{values.company && (
|
{values.company && (
|
||||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
@ -162,7 +164,7 @@ export function ReviewStep({ form }: ReviewStepProps) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values.acceptTerms}
|
checked={values.acceptTerms}
|
||||||
onChange={(e) => setValue("acceptTerms", e.target.checked)}
|
onChange={e => setValue("acceptTerms", e.target.checked)}
|
||||||
onBlur={() => setTouchedField("acceptTerms")}
|
onBlur={() => setTouchedField("acceptTerms")}
|
||||||
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values.marketingConsent ?? false}
|
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"
|
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">
|
<span className="text-sm text-gray-700">
|
||||||
Send me updates about new products and promotions
|
Send me updates about new products and promotions
|
||||||
<span className="block text-xs text-gray-500 mt-0.5">
|
<span className="block text-xs text-gray-500 mt-0.5">You can unsubscribe anytime</span>
|
||||||
You can unsubscribe anytime
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,10 +19,7 @@ interface ServiceManagementSectionProps {
|
|||||||
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
|
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
|
||||||
|
|
||||||
// Inner component that uses useSearchParams
|
// Inner component that uses useSearchParams
|
||||||
function ServiceManagementContent({
|
function ServiceManagementContent({ subscriptionId, productName }: ServiceManagementSectionProps) {
|
||||||
subscriptionId,
|
|
||||||
productName,
|
|
||||||
}: ServiceManagementSectionProps) {
|
|
||||||
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
|
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
|
||||||
|
|
||||||
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
||||||
@ -132,8 +129,6 @@ function ServiceManagementContent({
|
|||||||
|
|
||||||
// Wrapper component with Suspense boundary
|
// Wrapper component with Suspense boundary
|
||||||
export function ServiceManagementSection(props: ServiceManagementSectionProps) {
|
export function ServiceManagementSection(props: ServiceManagementSectionProps) {
|
||||||
const isSimService = useMemo(() => props.productName?.toLowerCase().includes("sim"), [props.productName]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@ -147,30 +147,6 @@ export function SimDetailsCard({
|
|||||||
const usedGB = totalGB - remainingGB;
|
const usedGB = totalGB - remainingGB;
|
||||||
const usagePercentage = (usedGB / totalGB) * 100;
|
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
|
// Usage Donut Component
|
||||||
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
||||||
const radius = (size - 16) / 2;
|
const radius = (size - 16) / 2;
|
||||||
@ -250,7 +226,6 @@ export function SimDetailsCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -366,9 +341,7 @@ export function SimDetailsCard({
|
|||||||
{simDetails.internationalRoamingEnabled && (
|
{simDetails.internationalRoamingEnabled && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<WifiIcon className="h-4 w-4 mr-1 text-green-500" />
|
<WifiIcon className="h-4 w-4 mr-1 text-green-500" />
|
||||||
<span className="text-sm text-green-600">
|
<span className="text-sm text-green-600">International Roaming Enabled</span>
|
||||||
International Roaming Enabled
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -381,9 +354,7 @@ export function SimDetailsCard({
|
|||||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
<div className="flex items-center text-sm">
|
<div className="flex items-center text-sm">
|
||||||
<ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
|
<ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
|
||||||
<span className="text-amber-800">
|
<span className="text-amber-800">Expires on {formatDate(simDetails.expiresAt)}</span>
|
||||||
Expires on {formatDate(simDetails.expiresAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
@ -57,23 +56,25 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
|
|
||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
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 navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||||
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
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
|
// Fetch subscription data
|
||||||
const { data: subscription } = useSubscription(subscriptionId);
|
const { data: subscription } = useSubscription(subscriptionId);
|
||||||
|
|
||||||
// Fetch latest invoice (limit 1)
|
// Fetch latest invoice (limit 1)
|
||||||
const { data: invoicesData } = useSubscriptionInvoices(subscriptionId, { limit: 1 });
|
const { data: invoicesData } = useSubscriptionInvoices(subscriptionId, { limit: 1 });
|
||||||
const latestInvoice = invoicesData?.invoices?.[0];
|
const latestInvoice = invoicesData?.invoices?.[0];
|
||||||
|
|
||||||
// SSO link mutation for payment
|
// SSO link mutation for payment
|
||||||
const createSsoLink = useCreateInvoiceSsoLink({
|
const createSsoLink = useCreateInvoiceSsoLink({
|
||||||
onSuccess: (data) => {
|
onSuccess: data => {
|
||||||
if (data.url) {
|
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 } },
|
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) {
|
if (!payload) {
|
||||||
throw new Error("Failed to load SIM information");
|
throw new Error("Failed to load SIM information");
|
||||||
}
|
}
|
||||||
|
|
||||||
setSimInfo({
|
setSimInfo({
|
||||||
details: payload.details,
|
details: payload.details,
|
||||||
usage: payload.usage
|
usage: payload.usage,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const hasStatus = (v: unknown): v is { status: number } =>
|
const hasStatus = (v: unknown): v is { status: number } =>
|
||||||
@ -121,13 +124,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
void fetchSimInfo();
|
void fetchSimInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionSuccess = () => {
|
|
||||||
void fetchSimInfo();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePayInvoice = () => {
|
const handlePayInvoice = () => {
|
||||||
if (latestInvoice?.id) {
|
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 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.monthlyUsageMb / 1000).toFixed(2)
|
||||||
: simInfo.usage?.todayUsageMb
|
: simInfo.usage?.todayUsageMb
|
||||||
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
|
? (simInfo.usage.todayUsageMb / 1000).toFixed(2)
|
||||||
: "0.00";
|
: "0.00";
|
||||||
|
|
||||||
// Calculate percentage for circle
|
// Calculate percentage for circle
|
||||||
const totalGB = parseFloat(remainingGB) + parseFloat(usedGB);
|
const totalGB = parseFloat(remainingGB) + parseFloat(usedGB);
|
||||||
@ -203,26 +202,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<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..."}
|
{subscription ? formatCurrency(subscription.amount) : "Loading..."}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<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"}
|
{subscription?.nextDue ? formatDate(subscription.nextDue) : "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Registered: <span className="font-semibold text-gray-900">
|
Registered:{" "}
|
||||||
{subscription?.registrationDate ? formatDate(subscription.registrationDate) : "N/A"}
|
<span className="font-semibold text-gray-900">
|
||||||
|
{subscription?.registrationDate
|
||||||
|
? formatDate(subscription.registrationDate)
|
||||||
|
: "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Latest Invoice Section - Mobile View */}
|
{/* Latest Invoice Section - Mobile View */}
|
||||||
{latestInvoice && (
|
{latestInvoice && (
|
||||||
<div className="lg:hidden mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handlePayInvoice}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top Up Data Button */}
|
{/* Top Up Data Button */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<button
|
<button
|
||||||
@ -255,14 +263,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="relative w-48 h-48">
|
<div className="relative w-48 h-48">
|
||||||
<svg className="w-full h-full transform -rotate-90">
|
<svg className="w-full h-full transform -rotate-90">
|
||||||
<circle
|
<circle cx="96" cy="96" r="88" fill="none" stroke="#e5e7eb" strokeWidth="12" />
|
||||||
cx="96"
|
|
||||||
cy="96"
|
|
||||||
r="88"
|
|
||||||
fill="none"
|
|
||||||
stroke="#e5e7eb"
|
|
||||||
strokeWidth="12"
|
|
||||||
/>
|
|
||||||
<circle
|
<circle
|
||||||
cx="96"
|
cx="96"
|
||||||
cy="96"
|
cy="96"
|
||||||
@ -296,7 +297,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
|
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
<span className="text-sm font-medium text-gray-900">Call History</span>
|
<span className="text-sm font-medium text-gray-900">Call History</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToChangePlan}
|
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"
|
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" />
|
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
<span className="text-sm font-medium text-gray-900">Change Plan</span>
|
<span className="text-sm font-medium text-gray-900">Change Plan</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToReissue}
|
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"
|
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" />
|
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToCancel}
|
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"
|
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>
|
<div>
|
||||||
<p className="font-medium text-gray-900">Network Type</p>
|
<p className="font-medium text-gray-900">Network Type</p>
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
{simInfo.details.networkType ? simInfo.details.networkType : 'Disabled'}
|
{simInfo.details.networkType ? simInfo.details.networkType : "Disabled"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
@ -421,11 +422,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="mr-2">•</span>
|
<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>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="mr-2">•</span>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { apiClient } from "@/lib/api";
|
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 {
|
import type {
|
||||||
SimTopUpRequest,
|
SimTopUpRequest,
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimReissueRequest,
|
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
// Types imported from domain - no duplication
|
// Types imported from domain - no duplication
|
||||||
@ -63,14 +67,18 @@ export const simActionsService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
|
async changePlanFull(
|
||||||
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
|
subscriptionId: string,
|
||||||
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
|
request: SimChangePlanFullRequest
|
||||||
{
|
): Promise<{ scheduledAt?: string }> {
|
||||||
params: { path: { subscriptionId } },
|
const response = await apiClient.POST<{
|
||||||
body: request,
|
success: boolean;
|
||||||
}
|
message: string;
|
||||||
);
|
scheduledAt?: string;
|
||||||
|
}>("/api/subscriptions/{subscriptionId}/sim/change-plan-full", {
|
||||||
|
params: { path: { subscriptionId } },
|
||||||
|
body: request,
|
||||||
|
});
|
||||||
return { scheduledAt: response.data?.scheduledAt };
|
return { scheduledAt: response.data?.scheduledAt };
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -160,12 +168,12 @@ export const simActionsService = {
|
|||||||
params.page = String(page);
|
params.page = String(page);
|
||||||
params.limit = String(limit);
|
params.limit = String(limit);
|
||||||
|
|
||||||
const response = await apiClient.GET<{ success: boolean; data: InternationalCallHistoryResponse }>(
|
const response = await apiClient.GET<{
|
||||||
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
|
success: boolean;
|
||||||
{
|
data: InternationalCallHistoryResponse;
|
||||||
params: { path: { subscriptionId }, query: params },
|
}>("/api/subscriptions/{subscriptionId}/sim/call-history/international", {
|
||||||
}
|
params: { path: { subscriptionId }, query: params },
|
||||||
);
|
});
|
||||||
return response.data?.data || null;
|
return response.data?.data || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,7 @@ import {
|
|||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import {
|
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY } from "@customer-portal/domain/support";
|
||||||
SUPPORT_CASE_STATUS,
|
|
||||||
SUPPORT_CASE_PRIORITY,
|
|
||||||
type SupportCaseStatus,
|
|
||||||
type SupportCasePriority,
|
|
||||||
} from "@customer-portal/domain/support";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status variant types for styling
|
* Status variant types for styling
|
||||||
@ -33,13 +28,15 @@ const ICON_SIZE_CLASSES: Record<IconSize, string> = {
|
|||||||
* Status to icon mapping
|
* Status to icon mapping
|
||||||
*/
|
*/
|
||||||
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
|
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
|
||||||
[SUPPORT_CASE_STATUS.RESOLVED]: (cls) => <CheckCircleIcon className={`${cls} text-green-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.CLOSED]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: (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.PENDING]: cls => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => <ClockIcon className={`${cls} text-blue-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.AWAITING_APPROVAL]: cls => (
|
||||||
[SUPPORT_CASE_STATUS.NEW]: (cls) => <SparklesIcon className={`${cls} text-purple-500`} />,
|
<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 {
|
export function getCaseStatusIcon(status: string, size: IconSize = "sm"): ReactNode {
|
||||||
const sizeClass = ICON_SIZE_CLASSES[size];
|
const sizeClass = ICON_SIZE_CLASSES[size];
|
||||||
const iconFn = STATUS_ICON_MAP[status];
|
const iconFn = STATUS_ICON_MAP[status];
|
||||||
|
|
||||||
if (iconFn) {
|
if (iconFn) {
|
||||||
return iconFn(sizeClass);
|
return iconFn(sizeClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ChatBubbleLeftRightIcon className={`${sizeClass} text-gray-400`} />;
|
return <ChatBubbleLeftRightIcon className={`${sizeClass} text-gray-400`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,4 +146,3 @@ export function getCasePriorityClasses(priority: string, withBorder = false): st
|
|||||||
const variant = getCasePriorityVariant(priority);
|
const variant = getCasePriorityVariant(priority);
|
||||||
return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant];
|
return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
CalendarIcon,
|
|
||||||
ClockIcon,
|
|
||||||
TagIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
|
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="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 flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">{getCaseStatusIcon(supportCase.status, "md")}</div>
|
||||||
{getCaseStatusIcon(supportCase.status, "md")}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
|
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
|
||||||
{supportCase.subject}
|
{supportCase.subject}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">Case #{supportCase.caseNumber}</p>
|
||||||
Case #{supportCase.caseNumber}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -112,7 +102,10 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-600">
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
<ClockIcon className="h-4 w-4 text-gray-400" />
|
<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>
|
</div>
|
||||||
{supportCase.category && (
|
{supportCase.category && (
|
||||||
<div className="flex items-center gap-2 text-gray-600">
|
<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 gap-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-blue-700">
|
<div className="text-sm text-blue-700">
|
||||||
<p className="font-medium">Need to update this case?</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftRightIcon,
|
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
@ -48,10 +47,7 @@ export function SupportHomeView() {
|
|||||||
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
|
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
|
||||||
Get instant answers to common questions about your account.
|
Get instant answers to common questions about your account.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button size="sm" className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm">
|
||||||
size="sm"
|
|
||||||
className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm"
|
|
||||||
>
|
|
||||||
Start Chat
|
Start Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -69,12 +65,7 @@ export function SupportHomeView() {
|
|||||||
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
|
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
|
||||||
Our team typically responds within 24 hours.
|
Our team typically responds within 24 hours.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button as="a" href="/support/new" size="sm" variant="outline">
|
||||||
as="a"
|
|
||||||
href="/support/new"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
New Case
|
New Case
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -119,9 +110,7 @@ export function SupportHomeView() {
|
|||||||
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
|
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
|
||||||
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">{getCaseStatusIcon(supportCase.status)}</div>
|
||||||
{getCaseStatusIcon(supportCase.status)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
|||||||
@ -248,4 +248,3 @@ export function useZodForm<TValues extends Record<string, unknown>>({
|
|||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ interface QueryProviderProps {
|
|||||||
nonce?: string;
|
nonce?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryProvider({ children, nonce }: QueryProviderProps) {
|
export function QueryProvider({ children }: QueryProviderProps) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
|
|||||||
9
env/portal-backend.env.sample
vendored
9
env/portal-backend.env.sample
vendored
@ -108,6 +108,15 @@ FREEBIT_OEM_KEY=CHANGE_ME
|
|||||||
# FREEBIT_TIMEOUT=30000 # default
|
# FREEBIT_TIMEOUT=30000 # default
|
||||||
# FREEBIT_RETRY_ATTEMPTS=3 # 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 (Email) ---
|
||||||
SENDGRID_API_KEY=CHANGE_ME
|
SENDGRID_API_KEY=CHANGE_ME
|
||||||
# EMAIL_ENABLED=true # default
|
# EMAIL_ENABLED=true # default
|
||||||
|
|||||||
@ -31,12 +31,28 @@ export default [
|
|||||||
rules: { "prettier/prettier": "warn" },
|
rules: { "prettier/prettier": "warn" },
|
||||||
},
|
},
|
||||||
|
|
||||||
// TypeScript type-checked rules for all TS files
|
// TypeScript recommended rules (fast, no type info)
|
||||||
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
...tseslint.configs.recommended.map((config) => ({
|
||||||
...config,
|
...config,
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
...(config.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 },
|
globals: { ...globals.node },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@ -75,21 +91,26 @@ export default [
|
|||||||
files: ["apps/portal/**/*.{js,jsx,ts,tsx}"],
|
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}"],
|
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}"],
|
files: ["apps/portal/**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: process.cwd(),
|
|
||||||
},
|
|
||||||
globals: { ...globals.browser, ...globals.node },
|
globals: { ...globals.browser, ...globals.node },
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
"@next/next/no-html-link-for-pages": "off",
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"test": "pnpm --recursive run test",
|
"test": "pnpm --recursive run test",
|
||||||
"lint": "pnpm --recursive run lint",
|
"lint": "pnpm --recursive run lint",
|
||||||
"lint:fix": "pnpm --recursive run lint:fix",
|
"lint:fix": "pnpm --recursive run lint:fix",
|
||||||
|
"lint-staged": "lint-staged",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@ -54,6 +55,7 @@
|
|||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.3",
|
"@types/node": "^24.10.3",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
@ -66,7 +68,6 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"js-yaml": ">=4.1.1",
|
"js-yaml": ">=4.1.1",
|
||||||
"glob": "^8.1.0",
|
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"@types/node": "24.10.3",
|
"@types/node": "24.10.3",
|
||||||
"zod": "4.1.13"
|
"zod": "4.1.13"
|
||||||
|
|||||||
11265
pnpm-lock.yaml
generated
11265
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user