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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.25.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
security-audit:
|
||||
name: Security Vulnerability Audit
|
||||
@ -23,47 +27,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
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: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run security audit
|
||||
id: audit
|
||||
run: |
|
||||
# Run audit and capture exit code
|
||||
pnpm audit --audit-level=high || echo "AUDIT_FAILED=true" >> $GITHUB_OUTPUT
|
||||
run: pnpm security:check
|
||||
|
||||
# Generate detailed report
|
||||
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: Generate audit report (JSON)
|
||||
if: always()
|
||||
run: pnpm audit --json > audit-report.json || true
|
||||
|
||||
- name: Upload audit report
|
||||
if: always()
|
||||
@ -107,6 +82,15 @@ jobs:
|
||||
languages: javascript-typescript
|
||||
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
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
@ -122,13 +106,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Check for outdated dependencies
|
||||
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 { CsrfMiddleware } from "./middleware/csrf.middleware.js";
|
||||
import { CsrfController } from "./controllers/csrf.controller.js";
|
||||
import { AdminGuard } from "./guards/admin.guard.js";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [CsrfController],
|
||||
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware],
|
||||
exports: [SecureErrorMapperService, CsrfService],
|
||||
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard],
|
||||
exports: [SecureErrorMapperService, CsrfService, AdminGuard],
|
||||
})
|
||||
export class SecurityModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@ -413,7 +413,8 @@ export class SimCallHistoryService {
|
||||
): Promise<DomesticCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
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";
|
||||
|
||||
// Default to available month if not specified
|
||||
@ -477,7 +478,8 @@ export class SimCallHistoryService {
|
||||
): Promise<InternationalCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
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";
|
||||
|
||||
// Default to available month if not specified
|
||||
@ -543,7 +545,8 @@ export class SimCallHistoryService {
|
||||
): Promise<SmsHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
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";
|
||||
|
||||
// Default to available month if not specified
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
BadRequestException,
|
||||
UsePipes,
|
||||
Header,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.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 { SimPlanService } from "./sim-management/services/sim-plan.service.js";
|
||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
|
||||
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
||||
import {
|
||||
EsimManagementService,
|
||||
type ReissueSimRequest,
|
||||
@ -117,18 +119,20 @@ export class SubscriptionsController {
|
||||
/**
|
||||
* List available files on SFTP for debugging
|
||||
*/
|
||||
@Public()
|
||||
@UseGuards(AdminGuard)
|
||||
@Get("sim/call-history/sftp-files")
|
||||
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);
|
||||
return { success: true, data: files, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual import of call history (admin only)
|
||||
* TODO: Add proper admin authentication before production
|
||||
*/
|
||||
@Public()
|
||||
@UseGuards(AdminGuard)
|
||||
@Post("sim/call-history/import")
|
||||
async importCallHistory(@Query("month") yearMonth: string) {
|
||||
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
||||
@ -163,6 +167,7 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get("debug/sim-details/:account")
|
||||
@UseGuards(AdminGuard)
|
||||
async debugSimDetails(@Param("account") account: string) {
|
||||
return await this.simManagementService.getSimDetailsDebug(account);
|
||||
}
|
||||
@ -191,6 +196,7 @@ export class SubscriptionsController {
|
||||
// ==================== SIM Management Endpoints (subscription-specific) ====================
|
||||
|
||||
@Get(":id/sim/debug")
|
||||
@UseGuards(AdminGuard)
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@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 { SimOrdersController } from "./sim-orders.controller.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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||
@ -14,6 +15,7 @@ import { InternetManagementModule } from "./internet-management/internet-managem
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SecurityModule,
|
||||
WhmcsModule,
|
||||
MappingsModule,
|
||||
FreebitModule,
|
||||
|
||||
@ -14,34 +14,19 @@ import { SupportService } from "./support.service.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import { z } from "zod";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
supportCaseFilterSchema,
|
||||
createCaseRequestSchema,
|
||||
publicContactRequestSchema,
|
||||
type SupportCaseFilter,
|
||||
type SupportCaseList,
|
||||
type SupportCase,
|
||||
type CreateCaseRequest,
|
||||
type CreateCaseResponse,
|
||||
type PublicContactRequest,
|
||||
} from "@customer-portal/domain/support";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.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);
|
||||
};
|
||||
import { hashEmailForLogs } from "./support.logging.js";
|
||||
|
||||
@Controller("support")
|
||||
export class SupportController {
|
||||
@ -87,7 +72,7 @@ export class SupportController {
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
|
||||
async publicContact(
|
||||
@Body(new ZodValidationPipe(publicContactSchema))
|
||||
@Body(new ZodValidationPipe(publicContactRequestSchema))
|
||||
body: PublicContactRequest
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
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 CreateCaseRequest,
|
||||
type CreateCaseResponse,
|
||||
type PublicContactRequest,
|
||||
} from "@customer-portal/domain/support";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { hashEmailForLogs } from "./support.logging.js";
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Creates a Web-to-Case in Salesforce or sends an email notification
|
||||
*/
|
||||
async createPublicContactRequest(request: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}): Promise<void> {
|
||||
this.logger.log("Creating public contact request", { email: request.email });
|
||||
async createPublicContactRequest(request: PublicContactRequest): Promise<void> {
|
||||
const emailHash = hashEmailForLogs(request.email);
|
||||
this.logger.log("Creating public contact request", { emailHash });
|
||||
|
||||
try {
|
||||
// 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", {
|
||||
email: request.email,
|
||||
emailHash,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create public contact request", {
|
||||
error: getErrorMessage(error),
|
||||
email: request.email,
|
||||
emailHash,
|
||||
});
|
||||
// Don't throw - we don't want to expose internal errors to public users
|
||||
// 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
|
||||
partialize: state => ({
|
||||
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";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { Button, Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { useZodForm } from "@/hooks/useZodForm";
|
||||
import { Mail, CheckCircle, MapPin } from "lucide-react";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
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 ContactFormData = z.infer<typeof contactFormSchema>;
|
||||
import {
|
||||
publicContactRequestSchema,
|
||||
type PublicContactRequest,
|
||||
} from "@customer-portal/domain/support";
|
||||
import { apiClient, ApiError, isApiError } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* PublicContactView - Contact page with form, phone, chat, and location info
|
||||
@ -26,29 +20,28 @@ export function PublicContactView() {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(async (data: ContactFormData) => {
|
||||
const handleSubmit = useCallback(async (data: PublicContactRequest) => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/support/contact", {
|
||||
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");
|
||||
}
|
||||
await apiClient.POST("/api/support/contact", { body: data });
|
||||
|
||||
setIsSubmitted(true);
|
||||
} 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");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useZodForm<ContactFormData>({
|
||||
schema: contactFormSchema,
|
||||
const form = useZodForm<PublicContactRequest>({
|
||||
schema: publicContactRequestSchema,
|
||||
initialValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
whmcsInvoiceRawSchema,
|
||||
type WhmcsInvoiceListItem,
|
||||
whmcsInvoiceListItemSchema,
|
||||
type WhmcsInvoiceItemsRaw,
|
||||
whmcsInvoiceItemsRawSchema,
|
||||
} from "./raw.types.js";
|
||||
import { parseAmount, formatDate } from "../../../providers/whmcs/utils.js";
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
* Minimal transformation - validates and normalizes only address structure.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { WhmcsClient, Address } from "../../schema.js";
|
||||
import { whmcsClientSchema, addressSchema } from "../../schema.js";
|
||||
import {
|
||||
|
||||
@ -235,8 +235,6 @@ const nullableProfileFields = [
|
||||
"lastlogin",
|
||||
] as const;
|
||||
|
||||
type NullableProfileKey = (typeof nullableProfileFields)[number];
|
||||
|
||||
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
|
||||
(acc, field) => {
|
||||
acc[field] = z.string().nullable().optional();
|
||||
|
||||
@ -5,12 +5,6 @@
|
||||
* 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 {
|
||||
CreateMappingRequest,
|
||||
UpdateMappingRequest,
|
||||
|
||||
@ -220,7 +220,6 @@ export function generateCancellationMonthOptions(
|
||||
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
|
||||
const availableMonths = generateCancellationMonthOptions(today);
|
||||
const earliestMonth = getEarliestCancellationMonth(today);
|
||||
const currentYearMonth = formatYearMonth(today);
|
||||
const day = today.getDate();
|
||||
|
||||
// Check if current month is still available
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
* 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 { SalesforceOrderRecord } from "./providers/salesforce/raw.types.js";
|
||||
import type { OrderConfigurations } from "./schema.js";
|
||||
|
||||
@ -4,12 +4,7 @@
|
||||
|
||||
import type { PaymentMethod, PaymentGateway } from "../../contract.js";
|
||||
import { paymentMethodSchema, paymentGatewaySchema } from "../../schema.js";
|
||||
import {
|
||||
type WhmcsPaymentMethodRaw,
|
||||
type WhmcsPaymentGatewayRaw,
|
||||
whmcsPaymentMethodRawSchema,
|
||||
whmcsPaymentGatewayRawSchema,
|
||||
} from "./raw.types.js";
|
||||
import { whmcsPaymentMethodRawSchema, whmcsPaymentGatewayRawSchema } from "./raw.types.js";
|
||||
|
||||
const PAYMENT_TYPE_MAP: Record<string, PaymentMethod["type"]> = {
|
||||
creditcard: "CreditCard",
|
||||
|
||||
@ -40,13 +40,6 @@ function coerceNumber(value: unknown): number | 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
|
||||
// ============================================================================
|
||||
|
||||
@ -5,17 +5,7 @@
|
||||
import type { SimDetails, SimUsage, SimTopUpHistory, SimType, SimStatus } from "../../contract.js";
|
||||
import { simDetailsSchema, simUsageSchema, simTopUpHistorySchema } from "../../schema.js";
|
||||
import {
|
||||
type FreebitAccountDetailsRaw,
|
||||
type FreebitTrafficInfoRaw,
|
||||
type FreebitQuotaHistoryRaw,
|
||||
type FreebitAuthResponseRaw,
|
||||
type FreebitTopUpRaw,
|
||||
type FreebitAddSpecRaw,
|
||||
type FreebitPlanChangeRaw,
|
||||
type FreebitCancelPlanRaw,
|
||||
type FreebitCancelAccountRaw,
|
||||
type FreebitEsimReissueRaw,
|
||||
type FreebitEsimAddAccountRaw,
|
||||
freebitAccountDetailsRawSchema,
|
||||
freebitTrafficInfoRawSchema,
|
||||
freebitQuotaHistoryRawSchema,
|
||||
@ -28,7 +18,6 @@ import {
|
||||
freebitEsimReissueRawSchema,
|
||||
freebitEsimAddAccountRawSchema,
|
||||
} from "./raw.types.js";
|
||||
import { normalizeAccount } from "./utils.js";
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
@ -90,6 +90,17 @@ export const createCaseResponseSchema = z.object({
|
||||
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 SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
|
||||
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 CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
|
||||
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
|
||||
export type PublicContactRequest = z.infer<typeof publicContactRequestSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user