Refactor GitHub Workflows to Consolidate Node and pnpm Setup
- Unified Node.js and pnpm setup across deploy, pr-checks, and security workflows by introducing a custom action for streamlined configuration. - Removed redundant setup steps to enhance workflow clarity and maintainability. - Updated security workflow to include concurrency control for better job management.
This commit is contained in:
parent
84a11f7efc
commit
b1ff1e8fd3
26
.github/actions/setup-node-pnpm/action.yml
vendored
Normal file
26
.github/actions/setup-node-pnpm/action.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Setup Node & pnpm
|
||||||
|
description: Setup Node.js and pnpm with pnpm store caching
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
node-version:
|
||||||
|
description: Node.js version to use
|
||||||
|
required: false
|
||||||
|
default: "22"
|
||||||
|
pnpm-version:
|
||||||
|
description: pnpm version to use
|
||||||
|
required: false
|
||||||
|
default: "10.25.0"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ inputs.pnpm-version }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ inputs.node-version }}
|
||||||
|
cache: pnpm
|
||||||
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@ -21,14 +21,8 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup Node & pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
12
.github/workflows/pr-checks.yml
vendored
12
.github/workflows/pr-checks.yml
vendored
@ -24,16 +24,8 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup Node & pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
with:
|
|
||||||
version: 10.25.0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
63
.github/workflows/security.yml
vendored
63
.github/workflows/security.yml
vendored
@ -14,6 +14,10 @@ on:
|
|||||||
# Allow manual trigger
|
# Allow manual trigger
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
security-audit:
|
security-audit:
|
||||||
name: Security Vulnerability Audit
|
name: Security Vulnerability Audit
|
||||||
@ -23,47 +27,18 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node & pnpm
|
||||||
uses: actions/setup-node@v4
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run security audit
|
- name: Run security audit
|
||||||
id: audit
|
run: pnpm security:check
|
||||||
run: |
|
|
||||||
# Run audit and capture exit code
|
|
||||||
pnpm audit --audit-level=high || echo "AUDIT_FAILED=true" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Generate detailed report
|
- name: Generate audit report (JSON)
|
||||||
pnpm audit --json > audit-report.json || true
|
if: always()
|
||||||
|
run: pnpm audit --json > audit-report.json || true
|
||||||
- name: Parse audit results
|
|
||||||
if: steps.audit.outputs.AUDIT_FAILED == 'true'
|
|
||||||
run: |
|
|
||||||
echo "⚠️ Security vulnerabilities detected!"
|
|
||||||
echo "Please review the audit report and update vulnerable packages."
|
|
||||||
pnpm audit
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Upload audit report
|
- name: Upload audit report
|
||||||
if: always()
|
if: always()
|
||||||
@ -107,6 +82,15 @@ jobs:
|
|||||||
languages: javascript-typescript
|
languages: javascript-typescript
|
||||||
queries: security-and-quality
|
queries: security-and-quality
|
||||||
|
|
||||||
|
- name: Setup Node & pnpm
|
||||||
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build (for better CodeQL extraction)
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v3
|
||||||
with:
|
with:
|
||||||
@ -122,13 +106,8 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node & pnpm
|
||||||
uses: actions/setup-node@v4
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Check for outdated dependencies
|
- name: Check for outdated dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
18
apps/bff/src/core/security/guards/admin.guard.ts
Normal file
18
apps/bff/src/core/security/guards/admin.guard.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
|
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
|
|
||||||
|
type RequestWithUserRole = {
|
||||||
|
user?: { role?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<RequestWithUserRole>();
|
||||||
|
const role = request?.user?.role;
|
||||||
|
if (role !== "ADMIN") {
|
||||||
|
throw new ForbiddenException("Admin access required");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,12 +5,13 @@ import { SecureErrorMapperService } from "./services/secure-error-mapper.service
|
|||||||
import { CsrfService } from "./services/csrf.service.js";
|
import { CsrfService } from "./services/csrf.service.js";
|
||||||
import { CsrfMiddleware } from "./middleware/csrf.middleware.js";
|
import { CsrfMiddleware } from "./middleware/csrf.middleware.js";
|
||||||
import { CsrfController } from "./controllers/csrf.controller.js";
|
import { CsrfController } from "./controllers/csrf.controller.js";
|
||||||
|
import { AdminGuard } from "./guards/admin.guard.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
controllers: [CsrfController],
|
controllers: [CsrfController],
|
||||||
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware],
|
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard],
|
||||||
exports: [SecureErrorMapperService, CsrfService],
|
exports: [SecureErrorMapperService, CsrfService, AdminGuard],
|
||||||
})
|
})
|
||||||
export class SecurityModule implements NestModule {
|
export class SecurityModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@ -413,7 +413,8 @@ export class SimCallHistoryService {
|
|||||||
): Promise<DomesticCallHistoryResponse> {
|
): Promise<DomesticCallHistoryResponse> {
|
||||||
// Validate subscription ownership
|
// Validate subscription ownership
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Use production phone number for call history (test number has no call data)
|
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
||||||
|
// TODO: Replace with the validated subscription account once call history data is available per user.
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
// Default to available month if not specified
|
||||||
@ -477,7 +478,8 @@ export class SimCallHistoryService {
|
|||||||
): Promise<InternationalCallHistoryResponse> {
|
): Promise<InternationalCallHistoryResponse> {
|
||||||
// Validate subscription ownership
|
// Validate subscription ownership
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Use production phone number for call history (test number has no call data)
|
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
||||||
|
// TODO: Replace with the validated subscription account once call history data is available per user.
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
// Default to available month if not specified
|
||||||
@ -543,7 +545,8 @@ export class SimCallHistoryService {
|
|||||||
): Promise<SmsHistoryResponse> {
|
): Promise<SmsHistoryResponse> {
|
||||||
// Validate subscription ownership
|
// Validate subscription ownership
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Use production phone number for SMS history (test number has no SMS data)
|
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
||||||
|
// TODO: Replace with the validated subscription account once call history data is available per user.
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
// Default to available month if not specified
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
Header,
|
Header,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
import { SubscriptionsService } from "./subscriptions.service.js";
|
import { SubscriptionsService } from "./subscriptions.service.js";
|
||||||
@ -46,6 +47,7 @@ import { ZodValidationPipe } from "nestjs-zod";
|
|||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
|
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
|
||||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
|
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
|
||||||
|
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
||||||
import {
|
import {
|
||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
type ReissueSimRequest,
|
type ReissueSimRequest,
|
||||||
@ -117,18 +119,20 @@ export class SubscriptionsController {
|
|||||||
/**
|
/**
|
||||||
* List available files on SFTP for debugging
|
* List available files on SFTP for debugging
|
||||||
*/
|
*/
|
||||||
@Public()
|
@UseGuards(AdminGuard)
|
||||||
@Get("sim/call-history/sftp-files")
|
@Get("sim/call-history/sftp-files")
|
||||||
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
|
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
|
||||||
|
if (!path.startsWith("/home/PASI")) {
|
||||||
|
throw new BadRequestException("Invalid path");
|
||||||
|
}
|
||||||
const files = await this.simCallHistoryService.listSftpFiles(path);
|
const files = await this.simCallHistoryService.listSftpFiles(path);
|
||||||
return { success: true, data: files, path };
|
return { success: true, data: files, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual import of call history (admin only)
|
* Trigger manual import of call history (admin only)
|
||||||
* TODO: Add proper admin authentication before production
|
|
||||||
*/
|
*/
|
||||||
@Public()
|
@UseGuards(AdminGuard)
|
||||||
@Post("sim/call-history/import")
|
@Post("sim/call-history/import")
|
||||||
async importCallHistory(@Query("month") yearMonth: string) {
|
async importCallHistory(@Query("month") yearMonth: string) {
|
||||||
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
||||||
@ -163,6 +167,7 @@ export class SubscriptionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("debug/sim-details/:account")
|
@Get("debug/sim-details/:account")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
async debugSimDetails(@Param("account") account: string) {
|
async debugSimDetails(@Param("account") account: string) {
|
||||||
return await this.simManagementService.getSimDetailsDebug(account);
|
return await this.simManagementService.getSimDetailsDebug(account);
|
||||||
}
|
}
|
||||||
@ -191,6 +196,7 @@ export class SubscriptionsController {
|
|||||||
// ==================== SIM Management Endpoints (subscription-specific) ====================
|
// ==================== SIM Management Endpoints (subscription-specific) ====================
|
||||||
|
|
||||||
@Get(":id/sim/debug")
|
@Get(":id/sim/debug")
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
async debugSimSubscription(
|
async debugSimSubscription(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { SimManagementService } from "./sim-management.service.js";
|
|||||||
import { SimUsageStoreService } from "./sim-usage-store.service.js";
|
import { SimUsageStoreService } from "./sim-usage-store.service.js";
|
||||||
import { SimOrdersController } from "./sim-orders.controller.js";
|
import { SimOrdersController } from "./sim-orders.controller.js";
|
||||||
import { SimOrderActivationService } from "./sim-order-activation.service.js";
|
import { SimOrderActivationService } from "./sim-order-activation.service.js";
|
||||||
|
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||||
@ -14,6 +15,7 @@ import { InternetManagementModule } from "./internet-management/internet-managem
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SecurityModule,
|
||||||
WhmcsModule,
|
WhmcsModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
FreebitModule,
|
FreebitModule,
|
||||||
|
|||||||
@ -14,34 +14,19 @@ import { SupportService } from "./support.service.js";
|
|||||||
import { ZodValidationPipe } from "nestjs-zod";
|
import { ZodValidationPipe } from "nestjs-zod";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||||
import { z } from "zod";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import {
|
import {
|
||||||
supportCaseFilterSchema,
|
supportCaseFilterSchema,
|
||||||
createCaseRequestSchema,
|
createCaseRequestSchema,
|
||||||
|
publicContactRequestSchema,
|
||||||
type SupportCaseFilter,
|
type SupportCaseFilter,
|
||||||
type SupportCaseList,
|
type SupportCaseList,
|
||||||
type SupportCase,
|
type SupportCase,
|
||||||
type CreateCaseRequest,
|
type CreateCaseRequest,
|
||||||
type CreateCaseResponse,
|
type CreateCaseResponse,
|
||||||
|
type PublicContactRequest,
|
||||||
} from "@customer-portal/domain/support";
|
} from "@customer-portal/domain/support";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
|
import { hashEmailForLogs } from "./support.logging.js";
|
||||||
// Public contact form schema
|
|
||||||
const publicContactSchema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required"),
|
|
||||||
email: z.string().email("Valid email required"),
|
|
||||||
phone: z.string().optional(),
|
|
||||||
subject: z.string().min(1, "Subject is required"),
|
|
||||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type PublicContactRequest = z.infer<typeof publicContactSchema>;
|
|
||||||
|
|
||||||
const hashEmailForLogs = (email: string): string => {
|
|
||||||
const normalized = email.trim().toLowerCase();
|
|
||||||
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
|
|
||||||
};
|
|
||||||
|
|
||||||
@Controller("support")
|
@Controller("support")
|
||||||
export class SupportController {
|
export class SupportController {
|
||||||
@ -87,7 +72,7 @@ export class SupportController {
|
|||||||
@UseGuards(RateLimitGuard)
|
@UseGuards(RateLimitGuard)
|
||||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
|
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
|
||||||
async publicContact(
|
async publicContact(
|
||||||
@Body(new ZodValidationPipe(publicContactSchema))
|
@Body(new ZodValidationPipe(publicContactRequestSchema))
|
||||||
body: PublicContactRequest
|
body: PublicContactRequest
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
|
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
|
||||||
|
|||||||
10
apps/bff/src/modules/support/support.logging.ts
Normal file
10
apps/bff/src/modules/support/support.logging.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash an email for logs (PII minimization).
|
||||||
|
* Use a short, stable prefix to correlate events without storing the email itself.
|
||||||
|
*/
|
||||||
|
export const hashEmailForLogs = (email: string): string => {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
|
||||||
|
};
|
||||||
@ -8,10 +8,12 @@ import {
|
|||||||
type SupportCaseList,
|
type SupportCaseList,
|
||||||
type CreateCaseRequest,
|
type CreateCaseRequest,
|
||||||
type CreateCaseResponse,
|
type CreateCaseResponse,
|
||||||
|
type PublicContactRequest,
|
||||||
} from "@customer-portal/domain/support";
|
} from "@customer-portal/domain/support";
|
||||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import { hashEmailForLogs } from "./support.logging.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status values that indicate an open/active case
|
* Status values that indicate an open/active case
|
||||||
@ -133,14 +135,9 @@ export class SupportService {
|
|||||||
* Create a contact request from public form (no authentication required)
|
* Create a contact request from public form (no authentication required)
|
||||||
* Creates a Web-to-Case in Salesforce or sends an email notification
|
* Creates a Web-to-Case in Salesforce or sends an email notification
|
||||||
*/
|
*/
|
||||||
async createPublicContactRequest(request: {
|
async createPublicContactRequest(request: PublicContactRequest): Promise<void> {
|
||||||
name: string;
|
const emailHash = hashEmailForLogs(request.email);
|
||||||
email: string;
|
this.logger.log("Creating public contact request", { emailHash });
|
||||||
phone?: string;
|
|
||||||
subject: string;
|
|
||||||
message: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
this.logger.log("Creating public contact request", { email: request.email });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a case without account association (Web-to-Case style)
|
// Create a case without account association (Web-to-Case style)
|
||||||
@ -155,12 +152,12 @@ export class SupportService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Public contact request created successfully", {
|
this.logger.log("Public contact request created successfully", {
|
||||||
email: request.email,
|
emailHash,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create public contact request", {
|
this.logger.error("Failed to create public contact request", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
email: request.email,
|
emailHash,
|
||||||
});
|
});
|
||||||
// Don't throw - we don't want to expose internal errors to public users
|
// Don't throw - we don't want to expose internal errors to public users
|
||||||
// In production, this should send a fallback email notification
|
// In production, this should send a fallback email notification
|
||||||
|
|||||||
@ -337,7 +337,13 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
// Only persist configuration state, not transient UI state
|
// Only persist configuration state, not transient UI state
|
||||||
partialize: state => ({
|
partialize: state => ({
|
||||||
internet: state.internet,
|
internet: state.internet,
|
||||||
sim: state.sim,
|
sim: {
|
||||||
|
...state.sim,
|
||||||
|
// Do NOT persist sensitive identifiers or MNP/identity fields in localStorage.
|
||||||
|
eid: "",
|
||||||
|
wantsMnp: false,
|
||||||
|
mnpData: { ...initialSimState.mnpData },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,23 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { z } from "zod";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Input } from "@/components/atoms";
|
import { Button, Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
import { Mail, CheckCircle, MapPin } from "lucide-react";
|
import { Mail, CheckCircle, MapPin } from "lucide-react";
|
||||||
|
import {
|
||||||
const contactFormSchema = z.object({
|
publicContactRequestSchema,
|
||||||
name: z.string().min(1, "Name is required"),
|
type PublicContactRequest,
|
||||||
email: z.string().email("Please enter a valid email address"),
|
} from "@customer-portal/domain/support";
|
||||||
phone: z.string().optional(),
|
import { apiClient, ApiError, isApiError } from "@/lib/api";
|
||||||
subject: z.string().min(1, "Subject is required"),
|
|
||||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicContactView - Contact page with form, phone, chat, and location info
|
* PublicContactView - Contact page with form, phone, chat, and location info
|
||||||
@ -26,29 +20,28 @@ export function PublicContactView() {
|
|||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (data: ContactFormData) => {
|
const handleSubmit = useCallback(async (data: PublicContactRequest) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/support/contact", {
|
await apiClient.POST("/api/support/contact", { body: data });
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error?.message || "Failed to send message");
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
setSubmitError(error.message || "Failed to send message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
setSubmitError(error.message || "Failed to send message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitError(error instanceof Error ? error.message : "Failed to send message");
|
setSubmitError(error instanceof Error ? error.message : "Failed to send message");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const form = useZodForm<ContactFormData>({
|
const form = useZodForm<PublicContactRequest>({
|
||||||
schema: contactFormSchema,
|
schema: publicContactRequestSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
whmcsInvoiceRawSchema,
|
whmcsInvoiceRawSchema,
|
||||||
type WhmcsInvoiceListItem,
|
type WhmcsInvoiceListItem,
|
||||||
whmcsInvoiceListItemSchema,
|
whmcsInvoiceListItemSchema,
|
||||||
type WhmcsInvoiceItemsRaw,
|
|
||||||
whmcsInvoiceItemsRawSchema,
|
whmcsInvoiceItemsRawSchema,
|
||||||
} from "./raw.types.js";
|
} from "./raw.types.js";
|
||||||
import { parseAmount, formatDate } from "../../../providers/whmcs/utils.js";
|
import { parseAmount, formatDate } from "../../../providers/whmcs/utils.js";
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
* Minimal transformation - validates and normalizes only address structure.
|
* Minimal transformation - validates and normalizes only address structure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { WhmcsClient, Address } from "../../schema.js";
|
import type { WhmcsClient, Address } from "../../schema.js";
|
||||||
import { whmcsClientSchema, addressSchema } from "../../schema.js";
|
import { whmcsClientSchema, addressSchema } from "../../schema.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -235,8 +235,6 @@ const nullableProfileFields = [
|
|||||||
"lastlogin",
|
"lastlogin",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type NullableProfileKey = (typeof nullableProfileFields)[number];
|
|
||||||
|
|
||||||
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
|
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
|
||||||
(acc, field) => {
|
(acc, field) => {
|
||||||
acc[field] = z.string().nullable().optional();
|
acc[field] = z.string().nullable().optional();
|
||||||
|
|||||||
@ -5,12 +5,6 @@
|
|||||||
* These functions contain no infrastructure dependencies (no DB, no HTTP, no logging).
|
* These functions contain no infrastructure dependencies (no DB, no HTTP, no logging).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
createMappingRequestSchema,
|
|
||||||
updateMappingRequestSchema,
|
|
||||||
userIdMappingSchema,
|
|
||||||
} from "./schema.js";
|
|
||||||
import type {
|
import type {
|
||||||
CreateMappingRequest,
|
CreateMappingRequest,
|
||||||
UpdateMappingRequest,
|
UpdateMappingRequest,
|
||||||
|
|||||||
@ -220,7 +220,6 @@ export function generateCancellationMonthOptions(
|
|||||||
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
|
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
|
||||||
const availableMonths = generateCancellationMonthOptions(today);
|
const availableMonths = generateCancellationMonthOptions(today);
|
||||||
const earliestMonth = getEarliestCancellationMonth(today);
|
const earliestMonth = getEarliestCancellationMonth(today);
|
||||||
const currentYearMonth = formatYearMonth(today);
|
|
||||||
const day = today.getDate();
|
const day = today.getDate();
|
||||||
|
|
||||||
// Check if current month is still available
|
// Check if current month is still available
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
* Validated types are derived from schemas (see schema.ts).
|
* Validated types are derived from schemas (see schema.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SalesforceProductFieldMap } from "../services/contract.js";
|
|
||||||
import type { SalesforceAccountFieldMap } from "../customer/index.js";
|
|
||||||
import type { UserIdMapping } from "../mappings/contract.js";
|
import type { UserIdMapping } from "../mappings/contract.js";
|
||||||
import type { SalesforceOrderRecord } from "./providers/salesforce/raw.types.js";
|
import type { SalesforceOrderRecord } from "./providers/salesforce/raw.types.js";
|
||||||
import type { OrderConfigurations } from "./schema.js";
|
import type { OrderConfigurations } from "./schema.js";
|
||||||
|
|||||||
@ -4,12 +4,7 @@
|
|||||||
|
|
||||||
import type { PaymentMethod, PaymentGateway } from "../../contract.js";
|
import type { PaymentMethod, PaymentGateway } from "../../contract.js";
|
||||||
import { paymentMethodSchema, paymentGatewaySchema } from "../../schema.js";
|
import { paymentMethodSchema, paymentGatewaySchema } from "../../schema.js";
|
||||||
import {
|
import { whmcsPaymentMethodRawSchema, whmcsPaymentGatewayRawSchema } from "./raw.types.js";
|
||||||
type WhmcsPaymentMethodRaw,
|
|
||||||
type WhmcsPaymentGatewayRaw,
|
|
||||||
whmcsPaymentMethodRawSchema,
|
|
||||||
whmcsPaymentGatewayRawSchema,
|
|
||||||
} from "./raw.types.js";
|
|
||||||
|
|
||||||
const PAYMENT_TYPE_MAP: Record<string, PaymentMethod["type"]> = {
|
const PAYMENT_TYPE_MAP: Record<string, PaymentMethod["type"]> = {
|
||||||
creditcard: "CreditCard",
|
creditcard: "CreditCard",
|
||||||
|
|||||||
@ -40,13 +40,6 @@ function coerceNumber(value: unknown): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
|
|
||||||
const normalized = sku.toLowerCase();
|
|
||||||
if (normalized.includes("24")) return "24-Month";
|
|
||||||
if (normalized.includes("12")) return "12-Month";
|
|
||||||
return "One-time";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Base Product Mapper
|
// Base Product Mapper
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -5,17 +5,7 @@
|
|||||||
import type { SimDetails, SimUsage, SimTopUpHistory, SimType, SimStatus } from "../../contract.js";
|
import type { SimDetails, SimUsage, SimTopUpHistory, SimType, SimStatus } from "../../contract.js";
|
||||||
import { simDetailsSchema, simUsageSchema, simTopUpHistorySchema } from "../../schema.js";
|
import { simDetailsSchema, simUsageSchema, simTopUpHistorySchema } from "../../schema.js";
|
||||||
import {
|
import {
|
||||||
type FreebitAccountDetailsRaw,
|
|
||||||
type FreebitTrafficInfoRaw,
|
|
||||||
type FreebitQuotaHistoryRaw,
|
|
||||||
type FreebitAuthResponseRaw,
|
type FreebitAuthResponseRaw,
|
||||||
type FreebitTopUpRaw,
|
|
||||||
type FreebitAddSpecRaw,
|
|
||||||
type FreebitPlanChangeRaw,
|
|
||||||
type FreebitCancelPlanRaw,
|
|
||||||
type FreebitCancelAccountRaw,
|
|
||||||
type FreebitEsimReissueRaw,
|
|
||||||
type FreebitEsimAddAccountRaw,
|
|
||||||
freebitAccountDetailsRawSchema,
|
freebitAccountDetailsRawSchema,
|
||||||
freebitTrafficInfoRawSchema,
|
freebitTrafficInfoRawSchema,
|
||||||
freebitQuotaHistoryRawSchema,
|
freebitQuotaHistoryRawSchema,
|
||||||
@ -28,7 +18,6 @@ import {
|
|||||||
freebitEsimReissueRawSchema,
|
freebitEsimReissueRawSchema,
|
||||||
freebitEsimAddAccountRawSchema,
|
freebitEsimAddAccountRawSchema,
|
||||||
} from "./raw.types.js";
|
} from "./raw.types.js";
|
||||||
import { normalizeAccount } from "./utils.js";
|
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
|
|||||||
@ -90,6 +90,17 @@ export const createCaseResponseSchema = z.object({
|
|||||||
caseNumber: z.string(),
|
caseNumber: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public contact form schema (unauthenticated)
|
||||||
|
*/
|
||||||
|
export const publicContactRequestSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
email: z.string().email("Valid email required"),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
subject: z.string().min(1, "Subject is required"),
|
||||||
|
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
|
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
|
||||||
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
|
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
|
||||||
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
|
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
|
||||||
@ -99,3 +110,4 @@ export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
|
|||||||
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
|
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
|
||||||
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
|
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
|
||||||
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
|
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
|
||||||
|
export type PublicContactRequest = z.infer<typeof publicContactRequestSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user