Update Configuration Files and Refactor Code Structure

- Adjusted .prettierrc to ensure consistent formatting with a newline at the end of the file.
- Reformatted eslint.config.mjs for improved readability by aligning array elements.
- Updated pnpm-lock.yaml to use single quotes for consistency across dependencies.
- Simplified worktree setup in .cursor/worktrees.json for cleaner configuration.
- Enhanced documentation in .cursor/plans to clarify architecture refactoring.
- Refactored various service files for improved readability and maintainability, including rate-limiting and auth services.
- Updated imports and exports across multiple files for consistency and clarity.
- Improved error handling and logging in service methods to enhance debugging capabilities.
- Streamlined utility functions for better performance and maintainability across the domain packages.
This commit is contained in:
barsa 2025-12-25 17:30:02 +09:00
parent 75dc6ec15d
commit 1b944f57aa
92 changed files with 509 additions and 456 deletions

View File

@ -1,4 +1,5 @@
<!-- 67f8fea5-b6cb-4187-8097-25ccb37e1dcf fa268fdd-dd67-4003-bb94-8236ed95ab44 --> <!-- 67f8fea5-b6cb-4187-8097-25ccb37e1dcf fa268fdd-dd67-4003-bb94-8236ed95ab44 -->
# Domain & BFF Clean Architecture Refactoring # Domain & BFF Clean Architecture Refactoring
## Overview ## Overview
@ -173,7 +174,7 @@ const result = this.orderWhmcsMapper.mapOrderItemsToWhmcs(items);
With: With:
```typescript ```typescript
import { Providers } from '@customer-portal/domain/orders'; import { Providers } from "@customer-portal/domain/orders";
const result = Providers.Whmcs.mapFulfillmentOrderItems(items); const result = Providers.Whmcs.mapFulfillmentOrderItems(items);
``` ```
@ -346,4 +347,4 @@ Flow: Query (BFF) → Raw Data → Domain Mapper → Domain Type → Use Directl
- [x] Update orders.module.ts and salesforce.module.ts with new services - [x] Update orders.module.ts and salesforce.module.ts with new services
- [x] Verify catalog services follow same clean pattern (already correct) - [x] Verify catalog services follow same clean pattern (already correct)
- [x] Update domain README and architecture documentation with clean patterns - [x] Update domain README and architecture documentation with clean patterns
- [x] Test order creation and fulfillment flows end-to-end - [x] Test order creation and fulfillment flows end-to-end

View File

@ -1,5 +1,3 @@
{ {
"setup-worktree": [ "setup-worktree": ["pnpm install"]
"pnpm install"
]
} }

View File

@ -1 +1 @@
"./config/prettier.config.js" "./config/prettier.config.js"

View File

@ -13,6 +13,7 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
## Directory Structure ## Directory Structure
### Development (Monorepo) ### Development (Monorepo)
``` ```
/project-root/ /project-root/
├── apps/bff/ ├── apps/bff/
@ -24,6 +25,7 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
``` ```
### Production (Docker Container) ### Production (Docker Container)
``` ```
/app/ /app/
├── prisma/ ├── prisma/
@ -36,12 +38,12 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
## Commands ## Commands
| Command | Description | | Command | Description |
|---------|-------------| | ------------------ | ---------------------------------------------------------- |
| `pnpm db:generate` | Regenerate Prisma client (`--schema=prisma/schema.prisma`) | | `pnpm db:generate` | Regenerate Prisma client (`--schema=prisma/schema.prisma`) |
| `pnpm db:migrate` | Run migrations (dev) with explicit schema path | | `pnpm db:migrate` | Run migrations (dev) with explicit schema path |
| `pnpm db:studio` | Open Prisma Studio with explicit schema path | | `pnpm db:studio` | Open Prisma Studio with explicit schema path |
| `pnpm db:reset` | Reset database with explicit schema path | | `pnpm db:reset` | Reset database with explicit schema path |
## Binary Targets ## Binary Targets

View File

@ -19,4 +19,3 @@ export default defineConfig({
url: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, url: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
}, },
}); });

View File

@ -1,4 +1,8 @@
export { RateLimitModule } from "./rate-limit.module.js"; export { RateLimitModule } from "./rate-limit.module.js";
export { RateLimitGuard } from "./rate-limit.guard.js"; export { RateLimitGuard } from "./rate-limit.guard.js";
export { RateLimit, SkipRateLimit, type RateLimitOptions, RATE_LIMIT_KEY } from "./rate-limit.decorator.js"; export {
RateLimit,
SkipRateLimit,
type RateLimitOptions,
RATE_LIMIT_KEY,
} from "./rate-limit.decorator.js";

View File

@ -41,4 +41,3 @@ export const RateLimit = (options: RateLimitOptions) => SetMetadata(RATE_LIMIT_K
* Skip rate limiting for this route (useful when applied at controller level) * Skip rate limiting for this route (useful when applied at controller level)
*/ */
export const SkipRateLimit = () => SetMetadata(RATE_LIMIT_KEY, { skip: true } as RateLimitOptions); export const SkipRateLimit = () => SetMetadata(RATE_LIMIT_KEY, { skip: true } as RateLimitOptions);

View File

@ -124,7 +124,10 @@ export class RateLimitGuard implements CanActivate {
/** /**
* Get or create a rate limiter for the given options * Get or create a rate limiter for the given options
*/ */
private getOrCreateLimiter(options: RateLimitOptions, context: ExecutionContext): RateLimiterRedis { private getOrCreateLimiter(
options: RateLimitOptions,
context: ExecutionContext
): RateLimiterRedis {
const handlerName = context.getHandler().name; const handlerName = context.getHandler().name;
const controllerName = context.getClass().name; const controllerName = context.getClass().name;
const cacheKey = `${controllerName}:${handlerName}:${options.limit}:${options.ttl}`; const cacheKey = `${controllerName}:${handlerName}:${options.limit}:${options.ttl}`;
@ -163,4 +166,3 @@ export class RateLimitGuard implements CanActivate {
} }
} }
} }

View File

@ -26,4 +26,3 @@ import { RateLimitGuard } from "./rate-limit.guard.js";
exports: [RateLimitGuard], exports: [RateLimitGuard],
}) })
export class RateLimitModule {} export class RateLimitModule {}

View File

@ -1,4 +1,10 @@
import { Inject, Injectable, InternalServerErrorException, HttpException, HttpStatus } from "@nestjs/common"; import {
Inject,
Injectable,
InternalServerErrorException,
HttpException,
HttpStatus,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { Request } from "express"; import type { Request } from "express";

View File

@ -39,17 +39,32 @@ export class BaseServicesService {
soql: string, soql: string,
context: string context: string
): Promise<TRecord[]> { ): Promise<TRecord[]> {
this.logger.debug(`Executing Salesforce query for ${context}`, {
soql: soql.replace(/\s+/g, " ").trim(),
portalPricebookId: this.portalPriceBookId,
portalCategoryField: this.portalCategoryField,
});
try { try {
const res = (await this.sf.query(soql, { const res = (await this.sf.query(soql, {
label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`, label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>; })) as SalesforceResponse<TRecord>;
return res.records ?? []; const records = res.records ?? [];
} catch (error: unknown) { this.logger.debug(`Query result for ${context}`, {
this.logger.error(`Query failed: ${context}`, { recordCount: records.length,
error: getErrorMessage(error), records: records.map(r => ({
soql, id: r.Id,
context, name: r.Name,
sku: r.StockKeepingUnit,
itemClass: r.Item_Class__c,
hasPricebookEntries: Boolean(r.PricebookEntries?.records?.length),
})),
}); });
return records;
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
this.logger.error(`Query failed: ${context} - ${errorMessage}`);
this.logger.error(`Failed SOQL: ${soql.replace(/\s+/g, " ").trim()}`);
return []; return [];
} }
} }

View File

@ -67,11 +67,7 @@ export class SimServicesService extends BaseServicesService {
return this.catalogCache.getCachedServices( return this.catalogCache.getCachedServices(
cacheKey, cacheKey,
async () => { async () => {
const soql = this.buildProductQuery("SIM", "Activation", [ const soql = this.buildProductQuery("SIM", "Activation", ["Catalog_Order__c"]);
"Catalog_Order__c",
"Auto_Add__c",
"Is_Default__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
"SIM Activation Fees" "SIM Activation Fees"

View File

@ -55,7 +55,10 @@ export class VpnServicesService extends BaseServicesService {
return this.catalogCache.getCachedServices( return this.catalogCache.getCachedServices(
cacheKey, cacheKey,
async () => { async () => {
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); const soql = this.buildProductQuery("VPN", "Activation", [
"VPN_Region__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
"VPN Activation Fees" "VPN Activation Fees"

View File

@ -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.

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"predev": "node ./scripts/dev-prep.mjs", "predev": "node ./scripts/dev-prep.mjs",
"dev": "next dev -p ${NEXT_PORT:-3000}", "dev": "next dev -p ${NEXT_PORT:-3000}",
"build": "next build --webpack", "build": "next build",
"build:turbo": "next build", "build:turbo": "next build",
"build:analyze": "ANALYZE=true next build", "build:analyze": "ANALYZE=true next build",
"analyze": "pnpm run build:analyze", "analyze": "pnpm run build:analyze",
@ -40,6 +40,7 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

View File

@ -1,5 +1,6 @@
/* Tailwind CSS v4 */ /* Tailwind CSS v4 */
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate";
@import "../styles/tokens.css"; @import "../styles/tokens.css";
@import "../styles/utilities.css"; @import "../styles/utilities.css";
@import "../styles/responsive.css"; @import "../styles/responsive.css";

View File

@ -17,8 +17,7 @@ const errorMessageVariants = cva("flex items-center gap-1 text-sm", {
}); });
interface ErrorMessageProps interface ErrorMessageProps
extends React.HTMLAttributes<HTMLParagraphElement>, extends React.HTMLAttributes<HTMLParagraphElement>, VariantProps<typeof errorMessageVariants> {
VariantProps<typeof errorMessageVariants> {
showIcon?: boolean; showIcon?: boolean;
} }

View File

@ -1,2 +1 @@
export { AgentforceWidget } from "./AgentforceWidget"; export { AgentforceWidget } from "./AgentforceWidget";

View File

@ -19,7 +19,7 @@ export const AuroraBackground = ({
<div <div
className={cn( className={cn(
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900", "transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
className, className
)} )}
{...props} {...props}
> >
@ -51,7 +51,7 @@ export const AuroraBackground = ({
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`, `after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
showRadialGradient && showRadialGradient &&
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`, `[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`
)} )}
></div> ></div>
</div> </div>

View File

@ -2,4 +2,3 @@ export { AccountStep } from "./AccountStep";
export { AddressStep } from "./AddressStep"; export { AddressStep } from "./AddressStep";
export { PasswordStep } from "./PasswordStep"; export { PasswordStep } from "./PasswordStep";
export { ReviewStep } from "./ReviewStep"; export { ReviewStep } from "./ReviewStep";

View File

@ -5,10 +5,7 @@
import type { OrderDisplayItem } from "@customer-portal/domain/orders"; import type { OrderDisplayItem } from "@customer-portal/domain/orders";
export { export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders";
buildOrderDisplayItems,
categorizeOrderItem,
} from "@customer-portal/domain/orders";
export type { export type {
OrderDisplayItem, OrderDisplayItem,
@ -20,10 +17,7 @@ export type {
/** /**
* Summarize order display items for compact display * Summarize order display items for compact display
*/ */
export function summarizeOrderDisplayItems( export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
items: OrderDisplayItem[],
fallback: string
): string {
if (items.length === 0) { if (items.length === 0) {
return fallback; return fallback;
} }

View File

@ -93,7 +93,7 @@ export function AddressConfirmation({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]); }, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed, setAddressConfirmed]);
useEffect(() => { useEffect(() => {
log.info("Address confirmation component mounted"); log.info("Address confirmation component mounted");

View File

@ -1,9 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import type { import type { SimTopUpPricing, SimTopUpPricingPreviewResponse } from "@customer-portal/domain/sim";
SimTopUpPricing,
SimTopUpPricingPreviewResponse,
} from "@customer-portal/domain/sim";
interface UseSimTopUpPricingResult { interface UseSimTopUpPricingResult {
pricing: SimTopUpPricing | null; pricing: SimTopUpPricing | null;
@ -24,7 +21,7 @@ export function useSimTopUpPricing(): UseSimTopUpPricingResult {
try { try {
setLoading(true); setLoading(true);
const response = await apiClient.GET("/api/subscriptions/sim/top-up/pricing"); const response = await apiClient.GET("/api/subscriptions/sim/top-up/pricing");
if (mounted && response.data) { if (mounted && response.data) {
const data = response.data as { success: boolean; data: SimTopUpPricing }; const data = response.data as { success: boolean; data: SimTopUpPricing };
setPricing(data.data); setPricing(data.data);
@ -66,4 +63,3 @@ export function useSimTopUpPricing(): UseSimTopUpPricingResult {
return { pricing, loading, error, calculatePreview }; return { pricing, loading, error, calculatePreview };
} }

View File

@ -42,7 +42,7 @@ export function getSimPlanSku(planCode?: string): string | undefined {
*/ */
export function mapToSimplifiedFormat(planCode?: string): string { export function mapToSimplifiedFormat(planCode?: string): string {
if (!planCode) return ""; if (!planCode) return "";
// Handle Freebit format (PASI_5G, PASI_25G, etc.) // Handle Freebit format (PASI_5G, PASI_25G, etc.)
if (planCode.startsWith("PASI_")) { if (planCode.startsWith("PASI_")) {
const match = planCode.match(/PASI_(\d+)G/); const match = planCode.match(/PASI_(\d+)G/);
@ -50,13 +50,13 @@ export function mapToSimplifiedFormat(planCode?: string): string {
return `${match[1]}GB`; return `${match[1]}GB`;
} }
} }
// Handle other formats that might end with G or GB // Handle other formats that might end with G or GB
const match = planCode.match(/(\d+)\s*G(?:B)?\b/i); const match = planCode.match(/(\d+)\s*G(?:B)?\b/i);
if (match) { if (match) {
return `${match[1]}GB`; return `${match[1]}GB`;
} }
// Return as-is if no pattern matches // Return as-is if no pattern matches
return planCode; return planCode;
} }

View File

@ -25,4 +25,3 @@ export function useCreateCase() {
}, },
}); });
} }

View File

@ -1,2 +1 @@
export * from "./case-presenters"; export * from "./case-presenters";

View File

@ -68,15 +68,15 @@ export const JAPAN_PREFECTURES: PrefectureOption[] = [
*/ */
export function formatJapanesePostalCode(value: string): string { export function formatJapanesePostalCode(value: string): string {
const digits = value.replace(/\D/g, ""); const digits = value.replace(/\D/g, "");
if (digits.length <= 3) { if (digits.length <= 3) {
return digits; return digits;
} }
if (digits.length <= 7) { if (digits.length <= 7) {
return `${digits.slice(0, 3)}-${digits.slice(3)}`; return `${digits.slice(0, 3)}-${digits.slice(3)}`;
} }
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`; return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`;
} }

View File

@ -1,6 +1,7 @@
# Complete Portainer Guide for Customer Portal # Complete Portainer Guide for Customer Portal
## Table of Contents ## Table of Contents
1. [Creating a Stack in Portainer](#creating-a-stack-in-portainer) 1. [Creating a Stack in Portainer](#creating-a-stack-in-portainer)
2. [Repository vs Upload vs Web Editor](#stack-creation-methods) 2. [Repository vs Upload vs Web Editor](#stack-creation-methods)
3. [Security Concerns & Best Practices](#security-concerns) 3. [Security Concerns & Best Practices](#security-concerns)
@ -22,6 +23,7 @@
Click **"+ Add stack"** button Click **"+ Add stack"** button
You'll see three creation methods: You'll see three creation methods:
- **Web editor** - Paste compose file directly - **Web editor** - Paste compose file directly
- **Upload** - Upload a compose file - **Upload** - Upload a compose file
- **Repository** - Pull from Git repository - **Repository** - Pull from Git repository
@ -45,16 +47,19 @@ Click **"Deploy the stack"**
### Method 1: Web Editor (Simplest) ### Method 1: Web Editor (Simplest)
**How:** **How:**
1. Select "Web editor" 1. Select "Web editor"
2. Paste your `docker-compose.yml` content 2. Paste your `docker-compose.yml` content
3. Add environment variables manually or load from file 3. Add environment variables manually or load from file
**Pros:** **Pros:**
- ✅ Quick and simple - ✅ Quick and simple
- ✅ No external dependencies - ✅ No external dependencies
- ✅ Full control over content - ✅ Full control over content
**Cons:** **Cons:**
- ❌ Manual updates required - ❌ Manual updates required
- ❌ No version control - ❌ No version control
- ❌ Easy to make mistakes when editing - ❌ Easy to make mistakes when editing
@ -66,17 +71,20 @@ Click **"Deploy the stack"**
### Method 2: Upload (Recommended for Your Case) ### Method 2: Upload (Recommended for Your Case)
**How:** **How:**
1. Select "Upload" 1. Select "Upload"
2. Upload your `docker-compose.yml` file 2. Upload your `docker-compose.yml` file
3. Optionally upload a `.env` file for environment variables 3. Optionally upload a `.env` file for environment variables
**Pros:** **Pros:**
- ✅ Version control on your local machine - ✅ Version control on your local machine
- ✅ Can prepare and test locally - ✅ Can prepare and test locally
- ✅ No external network dependencies - ✅ No external network dependencies
- ✅ Works in air-gapped environments - ✅ Works in air-gapped environments
**Cons:** **Cons:**
- ❌ Manual upload for each update - ❌ Manual upload for each update
- ❌ Need to manage files locally - ❌ Need to manage files locally
@ -87,12 +95,14 @@ Click **"Deploy the stack"**
### Method 3: Repository (Git Integration) ### Method 3: Repository (Git Integration)
**How:** **How:**
1. Select "Repository" 1. Select "Repository"
2. Enter repository URL (GitHub, GitLab, Bitbucket, etc.) 2. Enter repository URL (GitHub, GitLab, Bitbucket, etc.)
3. Specify branch and compose file path 3. Specify branch and compose file path
4. Add authentication if private repo 4. Add authentication if private repo
**Example Configuration:** **Example Configuration:**
``` ```
Repository URL: https://github.com/your-org/customer-portal Repository URL: https://github.com/your-org/customer-portal
Reference: main Reference: main
@ -100,16 +110,19 @@ Compose path: docker/portainer/docker-compose.yml
``` ```
**For Private Repos:** **For Private Repos:**
- Use a Personal Access Token (PAT) as password - Use a Personal Access Token (PAT) as password
- Or use deploy keys - Or use deploy keys
**Pros:** **Pros:**
- ✅ Version controlled - ✅ Version controlled
- ✅ Easy to update (just click "Pull and redeploy") - ✅ Easy to update (just click "Pull and redeploy")
- ✅ Team can review changes via PR - ✅ Team can review changes via PR
- ✅ Audit trail of changes - ✅ Audit trail of changes
**Cons:** **Cons:**
- ❌ Requires network access to repo - ❌ Requires network access to repo
- ❌ Secrets in repo = security risk - ❌ Secrets in repo = security risk
- ❌ Need to manage repo access tokens - ❌ Need to manage repo access tokens
@ -124,6 +137,7 @@ Compose path: docker/portainer/docker-compose.yml
**Use: Upload + Environment Variables in Portainer UI** **Use: Upload + Environment Variables in Portainer UI**
Why: Why:
1. Your compose file rarely changes (it's just orchestration) 1. Your compose file rarely changes (it's just orchestration)
2. Sensitive data stays in Portainer, not in Git 2. Sensitive data stays in Portainer, not in Git
3. Image updates are done via environment variables 3. Image updates are done via environment variables
@ -136,6 +150,7 @@ Why:
### 🔴 Critical Security Issues ### 🔴 Critical Security Issues
#### 1. Never Store Secrets in Git #### 1. Never Store Secrets in Git
```yaml ```yaml
# ❌ BAD - Secrets in compose file # ❌ BAD - Secrets in compose file
environment: environment:
@ -149,6 +164,7 @@ environment:
``` ```
#### 2. Never Store Secrets in Docker Images #### 2. Never Store Secrets in Docker Images
```dockerfile ```dockerfile
# ❌ BAD - Secrets baked into image # ❌ BAD - Secrets baked into image
ENV JWT_SECRET="my-secret" ENV JWT_SECRET="my-secret"
@ -159,6 +175,7 @@ COPY secrets/ /app/secrets/
``` ```
#### 3. Portainer Access Control #### 3. Portainer Access Control
``` ```
⚠️ Portainer has full Docker access = root on the host ⚠️ Portainer has full Docker access = root on the host
@ -173,6 +190,7 @@ Best practices:
### 🟡 Medium Security Concerns ### 🟡 Medium Security Concerns
#### 4. Environment Variables in Portainer #### 4. Environment Variables in Portainer
``` ```
Portainer stores env vars in its database. Portainer stores env vars in its database.
This is generally safe, but consider: This is generally safe, but consider:
@ -188,6 +206,7 @@ Mitigation:
``` ```
#### 5. Image Trust #### 5. Image Trust
``` ```
⚠️ You're loading .tar files - verify their integrity ⚠️ You're loading .tar files - verify their integrity
@ -198,6 +217,7 @@ Best practice:
``` ```
Add to build script: Add to build script:
```bash ```bash
# Generate checksums # Generate checksums
sha256sum portal-frontend.latest.tar > portal-frontend.latest.tar.sha256 sha256sum portal-frontend.latest.tar > portal-frontend.latest.tar.sha256
@ -209,6 +229,7 @@ sha256sum -c portal-backend.latest.tar.sha256
``` ```
#### 6. Network Exposure #### 6. Network Exposure
```yaml ```yaml
# ❌ BAD - Database exposed to host # ❌ BAD - Database exposed to host
database: database:
@ -225,6 +246,7 @@ database:
### 🟢 Good Security Practices (Already in Place) ### 🟢 Good Security Practices (Already in Place)
Your current setup does these right: Your current setup does these right:
- ✅ Non-root users in containers - ✅ Non-root users in containers
- ✅ Health checks configured - ✅ Health checks configured
- ✅ Database/Redis not exposed externally - ✅ Database/Redis not exposed externally
@ -252,12 +274,14 @@ watchtower:
``` ```
**Why NOT recommended:** **Why NOT recommended:**
- ❌ No control over when updates happen - ❌ No control over when updates happen
- ❌ No rollback mechanism - ❌ No rollback mechanism
- ❌ Can break production unexpectedly - ❌ Can break production unexpectedly
- ❌ Requires images in a registry (not .tar files) - ❌ Requires images in a registry (not .tar files)
We've disabled Watchtower in your compose: We've disabled Watchtower in your compose:
```yaml ```yaml
labels: labels:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"
@ -270,27 +294,32 @@ labels:
Portainer can expose a webhook URL that triggers stack redeployment. Portainer can expose a webhook URL that triggers stack redeployment.
**Setup:** **Setup:**
1. Go to Stack → Settings 1. Go to Stack → Settings
2. Enable "Webhook" 2. Enable "Webhook"
3. Copy the webhook URL 3. Copy the webhook URL
**Trigger from CI/CD:** **Trigger from CI/CD:**
```bash ```bash
# In your GitHub Actions / GitLab CI # In your GitHub Actions / GitLab CI
curl -X POST "https://your-portainer:9443/api/stacks/webhook/abc123" curl -X POST "https://your-portainer:9443/api/stacks/webhook/abc123"
``` ```
**Workflow:** **Workflow:**
``` ```
Build Images → Push to Registry → Trigger Webhook → Portainer Redeploys Build Images → Push to Registry → Trigger Webhook → Portainer Redeploys
``` ```
**Pros:** **Pros:**
- ✅ Controlled updates - ✅ Controlled updates
- ✅ Integrated with CI/CD - ✅ Integrated with CI/CD
- ✅ Can add approval gates - ✅ Can add approval gates
**Cons:** **Cons:**
- ❌ Requires images in a registry - ❌ Requires images in a registry
- ❌ Webhook URL is a secret - ❌ Webhook URL is a secret
- ❌ Limited rollback options - ❌ Limited rollback options
@ -313,6 +342,7 @@ ssh user@server "cd /path/to/portal && ./update-stack.sh v1.2.3"
``` ```
**Make it a one-liner:** **Make it a one-liner:**
```bash ```bash
# deploy.sh - Run locally # deploy.sh - Run locally
#!/bin/bash #!/bin/bash
@ -339,11 +369,13 @@ echo "✅ Deployed ${TAG}"
If you want auto-updates, use a registry: If you want auto-updates, use a registry:
**Free Options:** **Free Options:**
- GitHub Container Registry (ghcr.io) - free for public repos - GitHub Container Registry (ghcr.io) - free for public repos
- GitLab Container Registry - free - GitLab Container Registry - free
- Docker Hub - 1 private repo free - Docker Hub - 1 private repo free
**Setup:** **Setup:**
```bash ```bash
# Build and push # Build and push
./scripts/plesk/build-images.sh --tag v1.2.3 --push ghcr.io/your-org ./scripts/plesk/build-images.sh --tag v1.2.3 --push ghcr.io/your-org
@ -377,6 +409,7 @@ services:
``` ```
**Steps:** **Steps:**
1. Build: `./scripts/plesk/build-images.sh --tag 20241201-abc` 1. Build: `./scripts/plesk/build-images.sh --tag 20241201-abc`
2. Upload: `scp *.tar server:/path/images/` 2. Upload: `scp *.tar server:/path/images/`
3. Load: `docker load -i *.tar` 3. Load: `docker load -i *.tar`
@ -404,17 +437,19 @@ services:
## Quick Reference: Portainer Stack Commands ## Quick Reference: Portainer Stack Commands
### Via Portainer UI ### Via Portainer UI
| Action | Steps |
|--------|-------| | Action | Steps |
| Create stack | Stacks → Add stack → Configure → Deploy | | ------------ | -------------------------------------------------- |
| Update stack | Stacks → Select → Editor → Update | | Create stack | Stacks → Add stack → Configure → Deploy |
| Update stack | Stacks → Select → Editor → Update |
| Change image | Stacks → Select → Env vars → Change IMAGE → Update | | Change image | Stacks → Select → Env vars → Change IMAGE → Update |
| View logs | Stacks → Select → Container → Logs | | View logs | Stacks → Select → Container → Logs |
| Restart | Stacks → Select → Container → Restart | | Restart | Stacks → Select → Container → Restart |
| Stop | Stacks → Select → Stop | | Stop | Stacks → Select → Stop |
| Delete | Stacks → Select → Delete | | Delete | Stacks → Select → Delete |
### Via CLI (on server) ### Via CLI (on server)
```bash ```bash
# Navigate to stack directory # Navigate to stack directory
cd /path/to/portal cd /path/to/portal
@ -442,11 +477,10 @@ docker compose --env-file stack.env down -v
## Summary ## Summary
| Aspect | Recommendation | | Aspect | Recommendation |
|--------|---------------| | ------------------ | ------------------------------------------------------------------ |
| Stack creation | **Upload** method (version control locally, no secrets in git) | | Stack creation | **Upload** method (version control locally, no secrets in git) |
| Secrets management | **Portainer env vars** or **mounted secrets volume** | | Secrets management | **Portainer env vars** or **mounted secrets volume** |
| Image updates | **Manual script** for now, migrate to **registry + webhook** later | | Image updates | **Manual script** for now, migrate to **registry + webhook** later |
| Auto-updates | **Not recommended** for production; use controlled deployments | | Auto-updates | **Not recommended** for production; use controlled deployments |
| Rollback | Keep previous image tags, update env vars to rollback | | Rollback | Keep previous image tags, update env vars to rollback |

View File

@ -39,4 +39,3 @@ volumes:
networks: networks:
default: default:
name: portal_dev name: portal_dev

View File

@ -71,7 +71,10 @@ export default [
files: [...BFF_TS_FILES, "packages/domain/**/*.ts"], files: [...BFF_TS_FILES, "packages/domain/**/*.ts"],
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"no-console": ["warn", { allow: ["warn", "error"] }], "no-console": ["warn", { allow: ["warn", "error"] }],
}, },
}, },

View File

@ -72,18 +72,22 @@ packages/domain/
## 🎯 Design Principles ## 🎯 Design Principles
### 1. **Domain-First Organization** ### 1. **Domain-First Organization**
Each business domain owns its: Each business domain owns its:
- **`contract.ts`** - TypeScript interfaces (provider-agnostic) - **`contract.ts`** - TypeScript interfaces (provider-agnostic)
- **`schema.ts`** - Zod validation schemas (runtime safety) - **`schema.ts`** - Zod validation schemas (runtime safety)
- **`providers/`** - Provider-specific adapters (WHMCS, Salesforce, Freebit) - **`providers/`** - Provider-specific adapters (WHMCS, Salesforce, Freebit)
### 2. **Single Source of Truth** ### 2. **Single Source of Truth**
- ✅ All types defined in domain package - ✅ All types defined in domain package
- ✅ All validation schemas in domain package - ✅ All validation schemas in domain package
- ✅ No duplicate type definitions in apps - ✅ No duplicate type definitions in apps
- ✅ Shared between frontend (Next.js) and backend (NestJS) - ✅ Shared between frontend (Next.js) and backend (NestJS)
### 3. **Type Safety + Runtime Validation** ### 3. **Type Safety + Runtime Validation**
- TypeScript provides compile-time type checking - TypeScript provides compile-time type checking
- Zod schemas provide runtime validation - Zod schemas provide runtime validation
- Use `z.infer<typeof schema>` to derive types from schemas - Use `z.infer<typeof schema>` to derive types from schemas
@ -104,17 +108,19 @@ import { ApiResponse, PaginationParams } from "@customer-portal/domain/common";
### **API Response Handling** ### **API Response Handling**
```typescript ```typescript
import { import {
ApiResponse, ApiResponse,
ApiSuccessResponse, ApiSuccessResponse,
ApiErrorResponse, ApiErrorResponse,
apiResponseSchema apiResponseSchema,
} from "@customer-portal/domain/common"; } from "@customer-portal/domain/common";
// Type-safe API responses // Type-safe API responses
const response: ApiResponse<Invoice> = { const response: ApiResponse<Invoice> = {
success: true, success: true,
data: { /* invoice data */ } data: {
/* invoice data */
},
}; };
// With validation // With validation
@ -124,9 +130,9 @@ const validated = apiResponseSchema(invoiceSchema).parse(rawResponse);
### **Query Parameters with Validation** ### **Query Parameters with Validation**
```typescript ```typescript
import { import {
InvoiceQueryParams, InvoiceQueryParams,
invoiceQueryParamsSchema invoiceQueryParamsSchema
} from "@customer-portal/domain/billing"; } from "@customer-portal/domain/billing";
// In BFF controller // In BFF controller
@ -172,10 +178,7 @@ function LoginForm() {
```typescript ```typescript
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import { import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain/orders";
createOrderRequestSchema,
type CreateOrderRequest
} from "@customer-portal/domain/orders";
@Controller("orders") @Controller("orders")
export class OrdersController { export class OrdersController {
@ -194,39 +197,39 @@ export class OrdersController {
### **API Responses** ### **API Responses**
| Schema | Description | | Schema | Description |
|--------|-------------| | -------------------------------------- | ------------------------------------ |
| `apiSuccessResponseSchema(dataSchema)` | Successful API response wrapper | | `apiSuccessResponseSchema(dataSchema)` | Successful API response wrapper |
| `apiErrorResponseSchema` | Error API response with code/message | | `apiErrorResponseSchema` | Error API response with code/message |
| `apiResponseSchema(dataSchema)` | Discriminated union of success/error | | `apiResponseSchema(dataSchema)` | Discriminated union of success/error |
### **Pagination & Queries** ### **Pagination & Queries**
| Schema | Description | | Schema | Description |
|--------|-------------| | ------------------------------------- | ------------------------------ |
| `paginationParamsSchema` | Page, limit, offset parameters | | `paginationParamsSchema` | Page, limit, offset parameters |
| `paginatedResponseSchema(itemSchema)` | Paginated list response | | `paginatedResponseSchema(itemSchema)` | Paginated list response |
| `filterParamsSchema` | Search, sortBy, sortOrder | | `filterParamsSchema` | Search, sortBy, sortOrder |
| `queryParamsSchema` | Combined pagination + filters | | `queryParamsSchema` | Combined pagination + filters |
### **Domain-Specific Query Params** ### **Domain-Specific Query Params**
| Schema | Description | | Schema | Description |
|--------|-------------| | ------------------------------- | -------------------------------------- |
| `invoiceQueryParamsSchema` | Invoice list filtering (status, dates) | | `invoiceQueryParamsSchema` | Invoice list filtering (status, dates) |
| `subscriptionQueryParamsSchema` | Subscription filtering (status, type) | | `subscriptionQueryParamsSchema` | Subscription filtering (status, type) |
| `orderQueryParamsSchema` | Order filtering (status, orderType) | | `orderQueryParamsSchema` | Order filtering (status, orderType) |
### **Validation Primitives** ### **Validation Primitives**
| Schema | Description | | Schema | Description |
|--------|-------------| | ----------------- | ------------------------------------------------------- |
| `emailSchema` | Email validation (lowercase, trimmed) | | `emailSchema` | Email validation (lowercase, trimmed) |
| `passwordSchema` | Strong password (8+ chars, mixed case, number, special) | | `passwordSchema` | Strong password (8+ chars, mixed case, number, special) |
| `nameSchema` | Name validation (1-100 chars) | | `nameSchema` | Name validation (1-100 chars) |
| `phoneSchema` | Phone number validation | | `phoneSchema` | Phone number validation |
| `timestampSchema` | ISO datetime string | | `timestampSchema` | ISO datetime string |
| `dateSchema` | ISO date string | | `dateSchema` | ISO date string |
--- ---
@ -281,11 +284,11 @@ export * from "./schema";
```typescript ```typescript
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import { import {
myEntitySchema, myEntitySchema,
myEntityQueryParamsSchema, myEntityQueryParamsSchema,
type MyEntity, type MyEntity,
type MyEntityQueryParams type MyEntityQueryParams,
} from "@customer-portal/domain/my-domain"; } from "@customer-portal/domain/my-domain";
@Controller("my-entities") @Controller("my-entities")
@ -370,13 +373,15 @@ export const paginationSchema = z.object({
### 4. **Use Refinements for Complex Validation** ### 4. **Use Refinements for Complex Validation**
```typescript ```typescript
export const simActivationSchema = z.object({ export const simActivationSchema = z
simType: z.enum(["eSIM", "Physical SIM"]), .object({
eid: z.string().optional(), simType: z.enum(["eSIM", "Physical SIM"]),
}).refine( eid: z.string().optional(),
(data) => data.simType !== "eSIM" || (data.eid && data.eid.length >= 15), })
{ message: "EID required for eSIM", path: ["eid"] } .refine(data => data.simType !== "eSIM" || (data.eid && data.eid.length >= 15), {
); message: "EID required for eSIM",
path: ["eid"],
});
``` ```
--- ---
@ -428,4 +433,3 @@ When adding new types or schemas:
**Maintained by**: Customer Portal Team **Maintained by**: Customer Portal Team
**Last Updated**: October 2025 **Last Updated**: October 2025

View File

@ -11,4 +11,3 @@ type SignupRequestInput = z.input<typeof signupRequestSchema>;
export function buildSignupRequest(input: SignupRequestInput) { export function buildSignupRequest(input: SignupRequestInput) {
return signupRequestSchema.parse(input); return signupRequestSchema.parse(input);
} }

View File

@ -1,6 +1,6 @@
/** /**
* Billing Domain - Contract * Billing Domain - Contract
* *
* Constants and types for the billing domain. * Constants and types for the billing domain.
* All validated types are derived from schemas (see schema.ts). * All validated types are derived from schemas (see schema.ts).
*/ */
@ -35,5 +35,4 @@ export type {
BillingSummary, BillingSummary,
InvoiceQueryParams, InvoiceQueryParams,
InvoiceListQuery, InvoiceListQuery,
} from './schema.js'; } from "./schema.js";

View File

@ -1,8 +1,8 @@
/** /**
* Billing Domain * Billing Domain
* *
* Exports all billing-related contracts, schemas, and provider mappers. * Exports all billing-related contracts, schemas, and provider mappers.
* *
* Types are derived from Zod schemas (Schema-First Approach) * Types are derived from Zod schemas (Schema-First Approach)
*/ */
@ -25,7 +25,7 @@ export type {
BillingSummary, BillingSummary,
InvoiceQueryParams, InvoiceQueryParams,
InvoiceListQuery, InvoiceListQuery,
} from './schema.js'; } from "./schema.js";
// Provider adapters // Provider adapters
export * as Providers from "./providers/index.js"; export * as Providers from "./providers/index.js";

View File

@ -1,6 +1,6 @@
/** /**
* WHMCS Billing Provider - Mapper * WHMCS Billing Provider - Mapper
* *
* Transforms raw WHMCS invoice data into normalized billing domain types. * Transforms raw WHMCS invoice data into normalized billing domain types.
*/ */
@ -53,7 +53,7 @@ function mapItems(rawItems: unknown): InvoiceItem[] {
const parsed = whmcsInvoiceItemsRawSchema.parse(rawItems); const parsed = whmcsInvoiceItemsRawSchema.parse(rawItems);
const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item]; const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item];
return itemArray.map(item => ({ return itemArray.map(item => ({
id: item.id, id: item.id,
description: item.description, description: item.description,
@ -83,9 +83,7 @@ export function transformWhmcsInvoice(
const currency = whmcsInvoice.currencycode || options.defaultCurrencyCode || "JPY"; const currency = whmcsInvoice.currencycode || options.defaultCurrencyCode || "JPY";
const currencySymbol = const currencySymbol =
whmcsInvoice.currencyprefix || whmcsInvoice.currencyprefix || whmcsInvoice.currencysuffix || options.defaultCurrencySymbol;
whmcsInvoice.currencysuffix ||
options.defaultCurrencySymbol;
// Transform to domain model // Transform to domain model
const invoice: Invoice = { const invoice: Invoice = {

View File

@ -1,10 +1,10 @@
/** /**
* WHMCS Billing Provider - Raw Types * WHMCS Billing Provider - Raw Types
* *
* Type definitions for the WHMCS billing API contract: * Type definitions for the WHMCS billing API contract:
* - Request parameter types (API inputs) * - Request parameter types (API inputs)
* - Response types (API outputs) * - Response types (API outputs)
* *
* These represent the exact structure used by WHMCS APIs. * These represent the exact structure used by WHMCS APIs.
*/ */
@ -268,19 +268,27 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
/** /**
* WHMCS GetCurrencies API response schema * WHMCS GetCurrencies API response schema
* *
* WHMCS can return currencies in different formats: * WHMCS can return currencies in different formats:
* 1. Nested format: { currencies: { currency: [...] } } * 1. Nested format: { currencies: { currency: [...] } }
* 2. Flat format: currencies[currency][0][id], currencies[currency][0][code], etc. * 2. Flat format: currencies[currency][0][id], currencies[currency][0][code], etc.
* 3. Missing result field in some cases * 3. Missing result field in some cases
*/ */
export const whmcsCurrenciesResponseSchema = z.object({ export const whmcsCurrenciesResponseSchema = z
result: z.enum(["success", "error"]).optional(), .object({
totalresults: z.string().transform(val => parseInt(val, 10)).or(z.number()).optional(), result: z.enum(["success", "error"]).optional(),
currencies: z.object({ totalresults: z
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema), .string()
}).optional(), .transform(val => parseInt(val, 10))
// Allow any additional flat currency keys for flat format .or(z.number())
}).catchall(z.string().or(z.number())); .optional(),
currencies: z
.object({
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
})
.optional(),
// Allow any additional flat currency keys for flat format
})
.catchall(z.string().or(z.number()));
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>; export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;

View File

@ -1,6 +1,6 @@
/** /**
* Billing Domain - Schemas * Billing Domain - Schemas
* *
* Zod validation schemas for billing domain types. * Zod validation schemas for billing domain types.
* Used for runtime validation of data from any source. * Used for runtime validation of data from any source.
*/ */

View File

@ -15,4 +15,3 @@ export * as CommonProviders from "./providers/index.js";
// Re-export provider types for convenience // Re-export provider types for convenience
export type { WhmcsResponse, WhmcsErrorResponse } from "./providers/whmcs.js"; export type { WhmcsResponse, WhmcsErrorResponse } from "./providers/whmcs.js";
export type { SalesforceResponse } from "./providers/salesforce.js"; export type { SalesforceResponse } from "./providers/salesforce.js";

View File

@ -1,6 +1,6 @@
/** /**
* Common Provider Types * Common Provider Types
* *
* Generic provider-specific response structures used across multiple domains. * Generic provider-specific response structures used across multiple domains.
*/ */

View File

@ -1,6 +1,6 @@
/** /**
* Common Salesforce Provider Types * Common Salesforce Provider Types
* *
* Generic Salesforce API response structures used across multiple domains. * Generic Salesforce API response structures used across multiple domains.
*/ */
@ -24,10 +24,10 @@ type SalesforceResponseBase = z.infer<typeof salesforceResponseBaseSchema>;
/** /**
* Generic type for Salesforce query results derived from schema * Generic type for Salesforce query results derived from schema
* All SOQL queries return this structure regardless of SObject type * All SOQL queries return this structure regardless of SObject type
* *
* Usage: SalesforceResponse<SalesforceOrderRecord> * Usage: SalesforceResponse<SalesforceOrderRecord>
*/ */
export type SalesforceResponse<TRecord> = Omit<SalesforceResponseBase, 'records'> & { export type SalesforceResponse<TRecord> = Omit<SalesforceResponseBase, "records"> & {
records: TRecord[]; records: TRecord[];
}; };
@ -39,4 +39,3 @@ export const salesforceResponseSchema = <TRecord extends z.ZodTypeAny>(recordSch
salesforceResponseBaseSchema.extend({ salesforceResponseBaseSchema.extend({
records: z.array(recordSchema), records: z.array(recordSchema),
}); });

View File

@ -1,2 +1 @@
export * from "./raw.types.js"; export * from "./raw.types.js";

View File

@ -1,6 +1,6 @@
/** /**
* Common Salesforce Provider Types * Common Salesforce Provider Types
* *
* Generic Salesforce API response structures used across multiple domains. * Generic Salesforce API response structures used across multiple domains.
*/ */
@ -24,7 +24,7 @@ export const salesforceQueryResultSchema = <TRecord extends z.ZodTypeAny>(record
/** /**
* Generic type for Salesforce query results * Generic type for Salesforce query results
* All SOQL queries return this structure regardless of SObject type * All SOQL queries return this structure regardless of SObject type
* *
* Usage: SalesforceQueryResult<SalesforceOrderRecord> * Usage: SalesforceQueryResult<SalesforceOrderRecord>
*/ */
export interface SalesforceQueryResult<TRecord = unknown> { export interface SalesforceQueryResult<TRecord = unknown> {

View File

@ -1,6 +1,6 @@
/** /**
* Common WHMCS Provider Types * Common WHMCS Provider Types
* *
* Generic WHMCS API response structures used across multiple domains. * Generic WHMCS API response structures used across multiple domains.
*/ */
@ -23,7 +23,7 @@ type WhmcsResponseBase = z.infer<typeof whmcsResponseBaseSchema>;
/** /**
* Generic type for WHMCS API responses derived from schema * Generic type for WHMCS API responses derived from schema
* All WHMCS API endpoints return this structure * All WHMCS API endpoints return this structure
* *
* Usage: WhmcsResponse<InvoiceData> * Usage: WhmcsResponse<InvoiceData>
*/ */
export type WhmcsResponse<T> = WhmcsResponseBase & { export type WhmcsResponse<T> = WhmcsResponseBase & {
@ -53,4 +53,3 @@ export const whmcsErrorResponseSchema = z.object({
}); });
export type WhmcsErrorResponse = z.infer<typeof whmcsErrorResponseSchema>; export type WhmcsErrorResponse = z.infer<typeof whmcsErrorResponseSchema>;

View File

@ -20,7 +20,11 @@ export const passwordSchema = z
.regex(/[0-9]/, "Password must contain at least one number") .regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"); .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
export const nameSchema = z.string().min(1, "Name is required").max(100, "Name must be less than 100 characters").trim(); export const nameSchema = z
.string()
.min(1, "Name is required")
.max(100, "Name must be less than 100 characters")
.trim();
export const phoneSchema = z export const phoneSchema = z
.string() .string()
@ -78,7 +82,10 @@ export const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").t
/** /**
* Schema for validating SOQL field names * Schema for validating SOQL field names
*/ */
export const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name"); export const soqlFieldNameSchema = z
.string()
.trim()
.regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
// ============================================================================ // ============================================================================
// API Response Schemas // API Response Schemas
@ -111,10 +118,7 @@ export const apiErrorResponseSchema = z.object({
* Usage: apiResponseSchema(yourDataSchema) * Usage: apiResponseSchema(yourDataSchema)
*/ */
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.discriminatedUnion("success", [ z.discriminatedUnion("success", [apiSuccessResponseSchema(dataSchema), apiErrorResponseSchema]);
apiSuccessResponseSchema(dataSchema),
apiErrorResponseSchema,
]);
// ============================================================================ // ============================================================================
// Pagination Schemas // Pagination Schemas

View File

@ -1,6 +1,6 @@
/** /**
* Common Domain - Types * Common Domain - Types
* *
* Shared utility types and branded types used across all domains. * Shared utility types and branded types used across all domains.
*/ */

View File

@ -1,6 +1,6 @@
/** /**
* Common Domain - Validation Utilities * Common Domain - Validation Utilities
* *
* Generic validation functions used across all domains. * Generic validation functions used across all domains.
* These are pure functions with no infrastructure dependencies. * These are pure functions with no infrastructure dependencies.
*/ */
@ -26,23 +26,26 @@ export const customerNumberSchema = z.string().min(1, "Customer number is requir
/** /**
* Normalize and validate an email address * Normalize and validate an email address
* *
* This is a convenience wrapper that throws on invalid input. * This is a convenience wrapper that throws on invalid input.
* For validation without throwing, use the emailSchema directly with .safeParse() * For validation without throwing, use the emailSchema directly with .safeParse()
* *
* @throws Error if email format is invalid * @throws Error if email format is invalid
*/ */
export function normalizeAndValidateEmail(email: string): string { export function normalizeAndValidateEmail(email: string): string {
const emailValidationSchema = z.string().email().transform(e => e.toLowerCase().trim()); const emailValidationSchema = z
.string()
.email()
.transform(e => e.toLowerCase().trim());
return emailValidationSchema.parse(email); return emailValidationSchema.parse(email);
} }
/** /**
* Validate a UUID (v4) * Validate a UUID (v4)
* *
* This is a convenience wrapper that throws on invalid input. * This is a convenience wrapper that throws on invalid input.
* For validation without throwing, use the uuidSchema directly with .safeParse() * For validation without throwing, use the uuidSchema directly with .safeParse()
* *
* @throws Error if UUID format is invalid * @throws Error if UUID format is invalid
*/ */
export function validateUuidV4OrThrow(id: string): string { export function validateUuidV4OrThrow(id: string): string {
@ -74,10 +77,10 @@ export const urlSchema = z.string().url();
/** /**
* Validate a URL * Validate a URL
* *
* This is a convenience wrapper that throws on invalid input. * This is a convenience wrapper that throws on invalid input.
* For validation without throwing, use the urlSchema directly with .safeParse() * For validation without throwing, use the urlSchema directly with .safeParse()
* *
* @throws Error if URL format is invalid * @throws Error if URL format is invalid
*/ */
export function validateUrlOrThrow(url: string): string { export function validateUrlOrThrow(url: string): string {
@ -90,7 +93,7 @@ export function validateUrlOrThrow(url: string): string {
/** /**
* Validate a URL (non-throwing) * Validate a URL (non-throwing)
* *
* Returns validation result with errors if any. * Returns validation result with errors if any.
* Prefer using urlSchema.safeParse() directly for more control. * Prefer using urlSchema.safeParse() directly for more control.
*/ */

View File

@ -1,6 +1,6 @@
/** /**
* Customer Domain - Providers * Customer Domain - Providers
* *
* Providers handle mapping from external systems to domain types: * Providers handle mapping from external systems to domain types:
* - Portal: Prisma (portal DB) UserAuth * - Portal: Prisma (portal DB) UserAuth
* - Whmcs: WHMCS API WhmcsClient * - Whmcs: WHMCS API WhmcsClient

View File

@ -1,9 +1,8 @@
/** /**
* Portal Provider * Portal Provider
* *
* Handles mapping from Prisma (portal database) to UserAuth domain type * Handles mapping from Prisma (portal database) to UserAuth domain type
*/ */
export * from "./mapper.js"; export * from "./mapper.js";
export * from "./types.js"; export * from "./types.js";

View File

@ -1,6 +1,6 @@
/** /**
* Portal Provider - Mapper * Portal Provider - Mapper
* *
* Maps Prisma user data to UserAuth domain type using schema validation * Maps Prisma user data to UserAuth domain type using schema validation
*/ */
@ -10,9 +10,9 @@ import type { UserAuth } from "../../schema.js";
/** /**
* Maps raw Prisma user data to UserAuth domain type * Maps raw Prisma user data to UserAuth domain type
* *
* Uses schema validation for runtime type safety * Uses schema validation for runtime type safety
* *
* @param raw - Raw Prisma user data from portal database * @param raw - Raw Prisma user data from portal database
* @returns Validated UserAuth with only authentication state * @returns Validated UserAuth with only authentication state
*/ */
@ -28,4 +28,3 @@ export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth {
updatedAt: raw.updatedAt.toISOString(), updatedAt: raw.updatedAt.toISOString(),
}); });
} }

View File

@ -1,6 +1,6 @@
/** /**
* Portal Provider - Raw Types * Portal Provider - Raw Types
* *
* Raw Prisma user data interface. * Raw Prisma user data interface.
* Domain doesn't depend on @prisma/client directly. * Domain doesn't depend on @prisma/client directly.
*/ */
@ -24,4 +24,3 @@ export interface PrismaUserRaw {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

View File

@ -1,6 +1,6 @@
/** /**
* WHMCS Provider - Mapper * WHMCS Provider - Mapper
* *
* Maps WHMCS API responses to domain types. * Maps WHMCS API responses to domain types.
* Minimal transformation - validates and normalizes only address structure. * Minimal transformation - validates and normalizes only address structure.
*/ */
@ -32,7 +32,7 @@ export function transformWhmcsClientResponse(response: unknown): WhmcsClient {
/** /**
* Transform raw WHMCS client to domain WhmcsClient * Transform raw WHMCS client to domain WhmcsClient
* *
* Keeps raw WHMCS field names, only normalizes: * Keeps raw WHMCS field names, only normalizes:
* - Address structure to domain Address type * - Address structure to domain Address type
* - Type coercions (strings to numbers/booleans) * - Type coercions (strings to numbers/booleans)

View File

@ -3,27 +3,17 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import type { import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from "./contract.js";
CreateMappingRequest,
UpdateMappingRequest,
UserIdMapping,
} from "./contract.js";
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({ export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
whmcsClientId: z.number().int().positive(), whmcsClientId: z.number().int().positive(),
sfAccountId: z sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(),
.string()
.min(1, "Salesforce account ID must be at least 1 character")
.optional(),
}); });
export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({ export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({
whmcsClientId: z.number().int().positive().optional(), whmcsClientId: z.number().int().positive().optional(),
sfAccountId: z sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(),
.string()
.min(1, "Salesforce account ID must be at least 1 character")
.optional(),
}); });
export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({ export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({

View File

@ -1,6 +1,6 @@
/** /**
* ID Mapping Domain - Validation * ID Mapping Domain - Validation
* *
* Pure business validation functions for ID mappings. * Pure business validation functions for ID mappings.
* These functions contain no infrastructure dependencies (no DB, no HTTP, no logging). * These functions contain no infrastructure dependencies (no DB, no HTTP, no logging).
*/ */
@ -33,7 +33,7 @@ export function checkMappingCompleteness(request: CreateMappingRequest | UserIdM
/** /**
* Validate no conflicts exist with existing mappings * Validate no conflicts exist with existing mappings
* Business rule: Each userId, whmcsClientId should be unique * Business rule: Each userId, whmcsClientId should be unique
* *
* Note: This assumes the request has already been validated by schema. * Note: This assumes the request has already been validated by schema.
* Use createMappingRequestSchema.parse() before calling this function. * Use createMappingRequestSchema.parse() before calling this function.
*/ */
@ -72,11 +72,13 @@ export function validateNoConflicts(
/** /**
* Validate deletion constraints * Validate deletion constraints
* Business rule: Warn about data access impacts * Business rule: Warn about data access impacts
* *
* Note: This assumes the mapping has already been validated. * Note: This assumes the mapping has already been validated.
* This function adds business warnings about the impact of deletion. * This function adds business warnings about the impact of deletion.
*/ */
export function validateDeletion(mapping: UserIdMapping | null | undefined): MappingValidationResult { export function validateDeletion(
mapping: UserIdMapping | null | undefined
): MappingValidationResult {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
@ -85,9 +87,7 @@ export function validateDeletion(mapping: UserIdMapping | null | undefined): Map
return { isValid: false, errors, warnings }; return { isValid: false, errors, warnings };
} }
warnings.push( warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
);
if (mapping.sfAccountId) { if (mapping.sfAccountId) {
warnings.push( warnings.push(
"This mapping includes Salesforce integration - deletion will affect case management" "This mapping includes Salesforce integration - deletion will affect case management"
@ -99,7 +99,7 @@ export function validateDeletion(mapping: UserIdMapping | null | undefined): Map
/** /**
* Sanitize and normalize a create mapping request * Sanitize and normalize a create mapping request
* *
* Note: This performs basic string trimming before validation. * Note: This performs basic string trimming before validation.
* The schema handles validation; this is purely for data cleanup. * The schema handles validation; this is purely for data cleanup.
*/ */
@ -113,7 +113,7 @@ export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMapp
/** /**
* Sanitize and normalize an update mapping request * Sanitize and normalize an update mapping request
* *
* Note: This performs basic string trimming before validation. * Note: This performs basic string trimming before validation.
* The schema handles validation; this is purely for data cleanup. * The schema handles validation; this is purely for data cleanup.
*/ */
@ -130,4 +130,3 @@ export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMapp
return sanitized; return sanitized;
} }

View File

@ -1,6 +1,6 @@
/** /**
* Orders Domain - Checkout Types * Orders Domain - Checkout Types
* *
* Minimal type definitions for checkout flow. * Minimal type definitions for checkout flow.
* Frontend handles its own URL param serialization. * Frontend handles its own URL param serialization.
*/ */
@ -8,5 +8,5 @@
// This file is intentionally minimal after cleanup. // This file is intentionally minimal after cleanup.
// The build/derive/normalize functions were removed as they were // The build/derive/normalize functions were removed as they were
// unnecessary abstractions that should be handled by the frontend. // unnecessary abstractions that should be handled by the frontend.
// //
// See CLEANUP_PROPOSAL_NORMALIZERS.md for details. // See CLEANUP_PROPOSAL_NORMALIZERS.md for details.

View File

@ -64,7 +64,10 @@ const BILLING_CYCLE_ALIASES: Record<string, BillingCycle> = {
}; };
const normalizeBillingCycleKey = (value: string): string => const normalizeBillingCycleKey = (value: string): string =>
value.trim().toLowerCase().replace(/[\s_-]+/g, ""); value
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly"; const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly";
@ -337,7 +340,7 @@ export function buildOrderDisplayItems(
primaryCategory: category, primaryCategory: category,
categories: [category], categories: [category],
charges, charges,
included: charges.every((charge) => charge.amount <= 0), included: charges.every(charge => charge.amount <= 0),
sourceItems: [item], sourceItems: [item],
isBundle: Boolean(item.isBundledAddon), isBundle: Boolean(item.isBundledAddon),
}; };

View File

@ -1,16 +1,16 @@
/** /**
* Orders Domain * Orders Domain
* *
* Exports all order-related contracts, schemas, and provider mappers. * Exports all order-related contracts, schemas, and provider mappers.
* *
* Types are derived from Zod schemas (Schema-First Approach) * Types are derived from Zod schemas (Schema-First Approach)
*/ */
// Business types and constants // Business types and constants
export { export {
type OrderCreationType, type OrderCreationType,
type OrderStatus, type OrderStatus,
type OrderType, type OrderType,
type OrderTypeValue, type OrderTypeValue,
type UserMapping, type UserMapping,
// Checkout types // Checkout types
@ -79,7 +79,7 @@ export type {
OrderDisplayItemCategory, OrderDisplayItemCategory,
OrderDisplayItemCharge, OrderDisplayItemCharge,
OrderDisplayItemChargeKind, OrderDisplayItemChargeKind,
} from './schema.js'; } from "./schema.js";
// Provider adapters // Provider adapters
export * as Providers from "./providers/index.js"; export * as Providers from "./providers/index.js";

View File

@ -21,12 +21,7 @@ export const Salesforce = {
fieldMap: SalesforceFieldMap, fieldMap: SalesforceFieldMap,
}; };
export { export { WhmcsMapper, WhmcsRaw, SalesforceMapper, SalesforceRaw };
WhmcsMapper,
WhmcsRaw,
SalesforceMapper,
SalesforceRaw,
};
export * from "./whmcs/mapper.js"; export * from "./whmcs/mapper.js";
export * from "./whmcs/raw.types.js"; export * from "./whmcs/raw.types.js";
export * from "./salesforce/mapper.js"; export * from "./salesforce/mapper.js";

View File

@ -44,7 +44,7 @@ export function createOrderRequest(payload: {
/** /**
* Transform CheckoutCart into CreateOrderRequest * Transform CheckoutCart into CreateOrderRequest
* Handles SKU extraction, validation, and payload formatting * Handles SKU extraction, validation, and payload formatting
* *
* @throws Error if no products are selected * @throws Error if no products are selected
*/ */
export function prepareOrderFromCart( export function prepareOrderFromCart(
@ -65,13 +65,11 @@ export function prepareOrderFromCart(
// Note: Zod validation of the final structure should happen at the boundary or via schema.parse // Note: Zod validation of the final structure should happen at the boundary or via schema.parse
// This function focuses on the structural transformation logic. // This function focuses on the structural transformation logic.
const orderData: CreateOrderRequest = { const orderData: CreateOrderRequest = {
orderType, orderType,
skus: uniqueSkus, skus: uniqueSkus,
...(Object.keys(cart.configuration).length > 0 ...(Object.keys(cart.configuration).length > 0 ? { configurations: cart.configuration } : {}),
? { configurations: cart.configuration }
: {}),
}; };
return orderData; return orderData;

View File

@ -1,6 +1,6 @@
/** /**
* Payments Domain - Contract * Payments Domain - Contract
* *
* Constants and types for the payments domain. * Constants and types for the payments domain.
* All validated types are derived from schemas (see schema.ts). * All validated types are derived from schemas (see schema.ts).
*/ */
@ -53,4 +53,4 @@ export type {
PaymentGatewayType, PaymentGatewayType,
PaymentGateway, PaymentGateway,
PaymentGatewayList, PaymentGatewayList,
} from './schema.js'; } from "./schema.js";

View File

@ -1,8 +1,8 @@
/** /**
* Payments Domain * Payments Domain
* *
* Exports all payment-related contracts, schemas, and provider mappers. * Exports all payment-related contracts, schemas, and provider mappers.
* *
* Types are derived from Zod schemas (Schema-First Approach) * Types are derived from Zod schemas (Schema-First Approach)
*/ */
@ -20,7 +20,7 @@ export type {
PaymentGatewayType, PaymentGatewayType,
PaymentGateway, PaymentGateway,
PaymentGatewayList, PaymentGatewayList,
} from './schema.js'; } from "./schema.js";
// Provider adapters // Provider adapters
export * as Providers from "./providers/index.js"; export * as Providers from "./providers/index.js";

View File

@ -77,4 +77,3 @@ export function transformWhmcsPaymentGateway(raw: unknown): PaymentGateway {
return paymentGatewaySchema.parse(gateway); return paymentGatewaySchema.parse(gateway);
} }

View File

@ -4,4 +4,3 @@
*/ */
export * as Whmcs from "./whmcs/index.js"; export * as Whmcs from "./whmcs/index.js";

View File

@ -4,4 +4,3 @@
*/ */
export * from "./utils.js"; export * from "./utils.js";

View File

@ -1,6 +1,6 @@
/** /**
* SIM Domain - Contract * SIM Domain - Contract
* *
* Constants and types for the SIM domain. * Constants and types for the SIM domain.
* All validated types are derived from schemas (see schema.ts). * All validated types are derived from schemas (see schema.ts).
*/ */
@ -64,4 +64,4 @@ export type {
SimOrderActivationRequest, SimOrderActivationRequest,
SimOrderActivationMnp, SimOrderActivationMnp,
SimOrderActivationAddons, SimOrderActivationAddons,
} from './schema.js'; } from "./schema.js";

View File

@ -1,8 +1,8 @@
/** /**
* SIM Domain * SIM Domain
* *
* Exports all SIM-related contracts, schemas, and provider mappers. * Exports all SIM-related contracts, schemas, and provider mappers.
* *
* Types are derived from Zod schemas (Schema-First Approach) * Types are derived from Zod schemas (Schema-First Approach)
*/ */
@ -59,7 +59,7 @@ export type {
SimTopUpPricing, SimTopUpPricing,
SimTopUpPricingPreviewRequest, SimTopUpPricingPreviewRequest,
SimTopUpPricingPreviewResponse, SimTopUpPricingPreviewResponse,
} from './schema.js'; } from "./schema.js";
export type { SimPlanCode } from "./contract.js"; export type { SimPlanCode } from "./contract.js";
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js"; export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js";

View File

@ -138,4 +138,3 @@ export const SIM_MANAGEMENT_FLOW: ManagementFlow = {
}, },
], ],
}; };

View File

@ -47,8 +47,12 @@ export type PlanChangeResponse = ReturnType<typeof Mapper.transformFreebitPlanCh
export type CancelPlanResponse = ReturnType<typeof Mapper.transformFreebitCancelPlanResponse>; export type CancelPlanResponse = ReturnType<typeof Mapper.transformFreebitCancelPlanResponse>;
export type CancelAccountResponse = ReturnType<typeof Mapper.transformFreebitCancelAccountResponse>; export type CancelAccountResponse = ReturnType<typeof Mapper.transformFreebitCancelAccountResponse>;
export type EsimReissueResponse = ReturnType<typeof Mapper.transformFreebitEsimReissueResponse>; export type EsimReissueResponse = ReturnType<typeof Mapper.transformFreebitEsimReissueResponse>;
export type EsimAddAccountResponse = ReturnType<typeof Mapper.transformFreebitEsimAddAccountResponse>; export type EsimAddAccountResponse = ReturnType<
export type EsimActivationResponse = ReturnType<typeof Mapper.transformFreebitEsimActivationResponse>; typeof Mapper.transformFreebitEsimAddAccountResponse
>;
export type EsimActivationResponse = ReturnType<
typeof Mapper.transformFreebitEsimActivationResponse
>;
export type AuthResponse = ReturnType<typeof Mapper.transformFreebitAuthResponse>; export type AuthResponse = ReturnType<typeof Mapper.transformFreebitAuthResponse>;
export * from "./mapper.js"; export * from "./mapper.js";

View File

@ -48,11 +48,13 @@ export const freebitTrafficInfoRawSchema = z.object({
}) })
.optional(), .optional(),
account: z.union([z.string(), z.number()]).optional(), account: z.union([z.string(), z.number()]).optional(),
traffic: z.object({ traffic: z
today: z.union([z.string(), z.number()]).optional(), .object({
inRecentDays: z.union([z.string(), z.number()]).optional(), today: z.union([z.string(), z.number()]).optional(),
blackList: z.union([z.string(), z.number()]).optional(), inRecentDays: z.union([z.string(), z.number()]).optional(),
}).optional(), blackList: z.union([z.string(), z.number()]).optional(),
})
.optional(),
}); });
export const freebitTopUpRawSchema = z.object({ export const freebitTopUpRawSchema = z.object({
resultCode: z.string().optional(), resultCode: z.string().optional(),
@ -154,14 +156,16 @@ export const freebitQuotaHistoryRawSchema = z.object({
account: z.union([z.string(), z.number()]).optional(), account: z.union([z.string(), z.number()]).optional(),
total: z.union([z.string(), z.number()]).optional(), total: z.union([z.string(), z.number()]).optional(),
count: z.union([z.string(), z.number()]).optional(), count: z.union([z.string(), z.number()]).optional(),
quotaHistory: z.array( quotaHistory: z
z.object({ .array(
addQuotaKb: z.union([z.string(), z.number()]).optional(), z.object({
addDate: z.string().optional(), addQuotaKb: z.union([z.string(), z.number()]).optional(),
expireDate: z.string().optional(), addDate: z.string().optional(),
campaignCode: z.string().optional(), expireDate: z.string().optional(),
}) campaignCode: z.string().optional(),
).optional(), })
)
.optional(),
}); });
export type FreebitQuotaHistoryRaw = z.infer<typeof freebitQuotaHistoryRawSchema>; export type FreebitQuotaHistoryRaw = z.infer<typeof freebitQuotaHistoryRawSchema>;
@ -178,4 +182,3 @@ export const freebitAuthResponseRawSchema = z.object({
}); });
export type FreebitAuthResponseRaw = z.infer<typeof freebitAuthResponseRawSchema>; export type FreebitAuthResponseRaw = z.infer<typeof freebitAuthResponseRawSchema>;

View File

@ -1,6 +1,6 @@
/** /**
* SIM Domain - Freebit Provider Request Schemas * SIM Domain - Freebit Provider Request Schemas
* *
* Zod schemas for all Freebit API request payloads. * Zod schemas for all Freebit API request payloads.
*/ */
@ -135,7 +135,10 @@ export const freebitEsimAddAccountRequestSchema = z.object({
account: z.string().min(1, "Account is required"), account: z.string().min(1, "Account is required"),
eid: z.string().min(1, "EID is required"), eid: z.string().min(1, "EID is required"),
addKind: z.enum(["N", "R"]).default("N"), addKind: z.enum(["N", "R"]).default("N"),
shipDate: z.string().regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format").optional(), shipDate: z
.string()
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
.optional(),
planCode: z.string().optional(), planCode: z.string().optional(),
contractLine: z.enum(["4G", "5G"]).optional(), contractLine: z.enum(["4G", "5G"]).optional(),
mnp: freebitEsimMnpSchema.optional(), mnp: freebitEsimMnpSchema.optional(),
@ -178,7 +181,10 @@ export const freebitEsimIdentitySchema = z.object({
firstnameZenKana: z.string().optional(), firstnameZenKana: z.string().optional(),
lastnameZenKana: z.string().optional(), lastnameZenKana: z.string().optional(),
gender: z.enum(["M", "F"]).optional(), gender: z.enum(["M", "F"]).optional(),
birthday: z.string().regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format").optional(), birthday: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
}); });
/** /**
@ -194,7 +200,10 @@ export const freebitEsimActivationRequestSchema = z.object({
simkind: z.enum(["esim", "psim"]).default("esim"), simkind: z.enum(["esim", "psim"]).default("esim"),
planCode: z.string().optional(), planCode: z.string().optional(),
contractLine: z.enum(["4G", "5G"]).optional(), contractLine: z.enum(["4G", "5G"]).optional(),
shipDate: z.string().regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format").optional(), shipDate: z
.string()
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
.optional(),
mnp: freebitEsimMnpSchema.optional(), mnp: freebitEsimMnpSchema.optional(),
// Identity fields (flattened for API) // Identity fields (flattened for API)
firstnameKanji: z.string().optional(), firstnameKanji: z.string().optional(),
@ -202,7 +211,10 @@ export const freebitEsimActivationRequestSchema = z.object({
firstnameZenKana: z.string().optional(), firstnameZenKana: z.string().optional(),
lastnameZenKana: z.string().optional(), lastnameZenKana: z.string().optional(),
gender: z.enum(["M", "F"]).optional(), gender: z.enum(["M", "F"]).optional(),
birthday: z.string().regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format").optional(), birthday: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
// Additional fields for reissue/exchange // Additional fields for reissue/exchange
masterAccount: z.string().optional(), masterAccount: z.string().optional(),
masterPassword: z.string().optional(), masterPassword: z.string().optional(),
@ -224,10 +236,12 @@ export const freebitEsimActivationResponseSchema = z.object({
resultCode: z.string(), resultCode: z.string(),
resultMessage: z.string().optional(), resultMessage: z.string().optional(),
data: z.unknown().optional(), data: z.unknown().optional(),
status: z.object({ status: z
statusCode: z.union([z.string(), z.number()]), .object({
message: z.string(), statusCode: z.union([z.string(), z.number()]),
}).optional(), message: z.string(),
})
.optional(),
message: z.string().optional(), message: z.string().optional(),
}); });
@ -241,7 +255,10 @@ export const freebitEsimActivationParamsSchema = z.object({
planCode: z.string().optional(), planCode: z.string().optional(),
contractLine: z.enum(["4G", "5G"]).optional(), contractLine: z.enum(["4G", "5G"]).optional(),
aladinOperated: z.enum(["10", "20"]).default("10"), aladinOperated: z.enum(["10", "20"]).default("10"),
shipDate: z.string().regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format").optional(), shipDate: z
.string()
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
.optional(),
mnp: freebitEsimMnpSchema.optional(), mnp: freebitEsimMnpSchema.optional(),
identity: freebitEsimIdentitySchema.optional(), identity: freebitEsimIdentitySchema.optional(),
}); });
@ -271,4 +288,3 @@ export type FreebitQuotaHistoryResponse = z.infer<typeof freebitQuotaHistoryResp
export type FreebitEsimAddAccountRequest = z.infer<typeof freebitEsimAddAccountRequestSchema>; export type FreebitEsimAddAccountRequest = z.infer<typeof freebitEsimAddAccountRequestSchema>;
export type FreebitAuthRequest = z.infer<typeof freebitAuthRequestSchema>; export type FreebitAuthRequest = z.infer<typeof freebitAuthRequestSchema>;
export type FreebitCancelAccountRequest = z.infer<typeof freebitCancelAccountRequestSchema>; export type FreebitCancelAccountRequest = z.infer<typeof freebitCancelAccountRequestSchema>;

View File

@ -1,6 +1,6 @@
/** /**
* Freebit Provider Utilities * Freebit Provider Utilities
* *
* Provider-specific utilities for Freebit SIM API integration * Provider-specific utilities for Freebit SIM API integration
*/ */
@ -9,7 +9,7 @@
* Removes all non-digit characters from account string * Removes all non-digit characters from account string
*/ */
export function normalizeAccount(account: string): string { export function normalizeAccount(account: string): string {
return account.replace(/[^0-9]/g, ''); return account.replace(/[^0-9]/g, "");
} }
/** /**
@ -25,8 +25,8 @@ export function validateAccount(account: string): boolean {
*/ */
export function formatDateForApi(date: Date): string { export function formatDateForApi(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`; return `${year}${month}${day}`;
} }
@ -36,11 +36,10 @@ export function formatDateForApi(date: Date): string {
*/ */
export function parseDateFromApi(dateString: string): Date | null { export function parseDateFromApi(dateString: string): Date | null {
if (!/^\d{8}$/.test(dateString)) return null; if (!/^\d{8}$/.test(dateString)) return null;
const year = parseInt(dateString.substring(0, 4), 10); const year = parseInt(dateString.substring(0, 4), 10);
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
const day = parseInt(dateString.substring(6, 8), 10); const day = parseInt(dateString.substring(6, 8), 10);
return new Date(year, month, day); return new Date(year, month, day);
} }

View File

@ -138,12 +138,8 @@ export const simCancelRequestSchema = z.object({
}); });
export const simTopUpHistoryRequestSchema = z.object({ export const simTopUpHistoryRequestSchema = z.object({
fromDate: z fromDate: z.string().regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
.string() toDate: z.string().regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
.regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
toDate: z
.string()
.regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
}); });
export const simFeaturesUpdateRequestSchema = z.object({ export const simFeaturesUpdateRequestSchema = z.object({
@ -162,16 +158,19 @@ export const simReissueRequestSchema = z.object({
}); });
// Enhanced cancellation request with more details // Enhanced cancellation request with more details
export const simCancelFullRequestSchema = z.object({ export const simCancelFullRequestSchema = z
cancellationMonth: z.string().regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"), .object({
confirmRead: z.boolean(), cancellationMonth: z
confirmCancel: z.boolean(), .string()
alternativeEmail: z.string().email().optional().or(z.literal("")), .regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
comments: z.string().max(1000).optional(), confirmRead: z.boolean(),
}).refine( confirmCancel: z.boolean(),
(data) => data.confirmRead === true && data.confirmCancel === true, alternativeEmail: z.string().email().optional().or(z.literal("")),
{ message: "You must confirm both checkboxes to proceed" } comments: z.string().max(1000).optional(),
); })
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
message: "You must confirm both checkboxes to proceed",
});
// Top-up request with enhanced details for email // Top-up request with enhanced details for email
export const simTopUpFullRequestSchema = z.object({ export const simTopUpFullRequestSchema = z.object({
@ -292,7 +291,10 @@ export const simOrderActivationMnpSchema = z.object({
firstnameZenKana: z.string().optional(), firstnameZenKana: z.string().optional(),
lastnameZenKana: z.string().optional(), lastnameZenKana: z.string().optional(),
gender: z.string().optional(), gender: z.string().optional(),
birthday: z.string().regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format").optional(), birthday: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
}); });
export const simOrderActivationAddonsSchema = z.object({ export const simOrderActivationAddonsSchema = z.object({
@ -300,42 +302,48 @@ export const simOrderActivationAddonsSchema = z.object({
callWaiting: z.boolean().optional(), callWaiting: z.boolean().optional(),
}); });
export const simOrderActivationRequestSchema = z.object({ export const simOrderActivationRequestSchema = z
planSku: z.string().min(1, "Plan SKU is required"), .object({
simType: simCardTypeSchema, planSku: z.string().min(1, "Plan SKU is required"),
eid: z.string().min(15, "EID must be at least 15 characters").optional(), simType: simCardTypeSchema,
activationType: simActivationTypeSchema, eid: z.string().min(15, "EID must be at least 15 characters").optional(),
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(), activationType: simActivationTypeSchema,
addons: simOrderActivationAddonsSchema.optional(), scheduledAt: z
mnp: simOrderActivationMnpSchema.optional(), .string()
msisdn: z.string().min(1, "Phone number (msisdn) is required"), .regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
oneTimeAmountJpy: z.number().nonnegative("One-time amount must be non-negative"), .optional(),
monthlyAmountJpy: z.number().nonnegative("Monthly amount must be non-negative"), addons: simOrderActivationAddonsSchema.optional(),
}).refine( mnp: simOrderActivationMnpSchema.optional(),
(data) => { msisdn: z.string().min(1, "Phone number (msisdn) is required"),
// If simType is eSIM, eid is required oneTimeAmountJpy: z.number().nonnegative("One-time amount must be non-negative"),
if (data.simType === "eSIM" && (!data.eid || data.eid.length < 15)) { monthlyAmountJpy: z.number().nonnegative("Monthly amount must be non-negative"),
return false; })
.refine(
data => {
// If simType is eSIM, eid is required
if (data.simType === "eSIM" && (!data.eid || data.eid.length < 15)) {
return false;
}
return true;
},
{
message: "EID is required for eSIM and must be at least 15 characters",
path: ["eid"],
} }
return true; )
}, .refine(
{ data => {
message: "EID is required for eSIM and must be at least 15 characters", // If activationType is Scheduled, scheduledAt is required
path: ["eid"], if (data.activationType === "Scheduled" && !data.scheduledAt) {
} return false;
).refine( }
(data) => { return true;
// If activationType is Scheduled, scheduledAt is required },
if (data.activationType === "Scheduled" && !data.scheduledAt) { {
return false; message: "Scheduled date is required for Scheduled activation",
path: ["scheduledAt"],
} }
return true; );
},
{
message: "Scheduled date is required for Scheduled activation",
path: ["scheduledAt"],
}
);
export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequestSchema>; export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequestSchema>;
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>; export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;

View File

@ -1,6 +1,6 @@
/** /**
* Subscriptions Domain - Contract * Subscriptions Domain - Contract
* *
* Constants and types for the subscriptions domain. * Constants and types for the subscriptions domain.
* All validated types are derived from schemas (see schema.ts). * All validated types are derived from schemas (see schema.ts).
*/ */
@ -45,4 +45,4 @@ export type {
SubscriptionList, SubscriptionList,
SubscriptionQueryParams, SubscriptionQueryParams,
SubscriptionQuery, SubscriptionQuery,
} from './schema.js'; } from "./schema.js";

View File

@ -1,6 +1,6 @@
/** /**
* WHMCS Subscriptions Provider - Raw Types * WHMCS Subscriptions Provider - Raw Types
* *
* Type definitions for the WHMCS subscriptions API contract: * Type definitions for the WHMCS subscriptions API contract:
* - Request parameter types (API inputs) * - Request parameter types (API inputs)
* - Response types (API outputs) * - Response types (API outputs)
@ -8,30 +8,24 @@
import { z } from "zod"; import { z } from "zod";
const normalizeRequiredNumber = z.preprocess( const normalizeRequiredNumber = z.preprocess(value => {
value => { if (typeof value === "number") return value;
if (typeof value === "number") return value; if (typeof value === "string" && value.trim().length > 0) {
if (typeof value === "string" && value.trim().length > 0) { const parsed = Number(value);
const parsed = Number(value); return Number.isFinite(parsed) ? parsed : value;
return Number.isFinite(parsed) ? parsed : value; }
} return value;
return value; }, z.number());
},
z.number()
);
const normalizeOptionalNumber = z.preprocess( const normalizeOptionalNumber = z.preprocess(value => {
value => { if (value === undefined || value === null || value === "") return undefined;
if (value === undefined || value === null || value === "") return undefined; if (typeof value === "number") return value;
if (typeof value === "number") return value; if (typeof value === "string") {
if (typeof value === "string") { const parsed = Number(value);
const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined;
return Number.isFinite(parsed) ? parsed : undefined; }
} return undefined;
return undefined; }, z.number().optional());
},
z.number().optional()
);
const optionalStringField = () => const optionalStringField = () =>
z z
@ -117,7 +111,7 @@ export const whmcsProductRawSchema = z.object({
ns1: optionalStringField(), ns1: optionalStringField(),
ns2: optionalStringField(), ns2: optionalStringField(),
assignedips: optionalStringField(), assignedips: optionalStringField(),
// Pricing // Pricing
firstpaymentamount: z.union([z.string(), z.number()]).optional(), firstpaymentamount: z.union([z.string(), z.number()]).optional(),
amount: z.union([z.string(), z.number()]).optional(), amount: z.union([z.string(), z.number()]).optional(),
@ -125,16 +119,16 @@ export const whmcsProductRawSchema = z.object({
billingcycle: z.string().optional(), billingcycle: z.string().optional(),
paymentmethod: z.string().optional(), paymentmethod: z.string().optional(),
paymentmethodname: z.string().optional(), paymentmethodname: z.string().optional(),
// Dates // Dates
nextduedate: z.string().optional(), nextduedate: z.string().optional(),
nextinvoicedate: z.string().optional(), nextinvoicedate: z.string().optional(),
// Status // Status
status: z.string(), status: z.string(),
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
// Notes // Notes
notes: z.string().optional(), notes: z.string().optional(),
diskusage: normalizeOptionalNumber, diskusage: normalizeOptionalNumber,
@ -142,18 +136,20 @@ export const whmcsProductRawSchema = z.object({
bwusage: normalizeOptionalNumber, bwusage: normalizeOptionalNumber,
bwlimit: normalizeOptionalNumber, bwlimit: normalizeOptionalNumber,
lastupdate: z.string().optional(), lastupdate: z.string().optional(),
// Custom fields // Custom fields
customfields: whmcsCustomFieldsContainerSchema.optional(), customfields: whmcsCustomFieldsContainerSchema.optional(),
configoptions: whmcsConfigOptionsContainerSchema.optional(), configoptions: whmcsConfigOptionsContainerSchema.optional(),
// Pricing details // Pricing details
pricing: z.object({ pricing: z
amount: z.union([z.string(), z.number()]).optional(), .object({
currency: z.string().optional(), amount: z.union([z.string(), z.number()]).optional(),
currencyprefix: z.string().optional(), currency: z.string().optional(),
currencysuffix: z.string().optional(), currencyprefix: z.string().optional(),
}).optional(), currencysuffix: z.string().optional(),
})
.optional(),
}); });
export type WhmcsProductRaw = z.infer<typeof whmcsProductRawSchema>; export type WhmcsProductRaw = z.infer<typeof whmcsProductRawSchema>;
@ -167,13 +163,15 @@ export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
* WHMCS GetClientsProducts API response schema * WHMCS GetClientsProducts API response schema
*/ */
const whmcsProductContainerSchema = z.object({ const whmcsProductContainerSchema = z.object({
product: z product: z.preprocess(
.preprocess(value => { value => {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return undefined; return undefined;
} }
return value; return value;
}, z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional()), },
z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional()
),
}); });
export const whmcsProductListResponseSchema = z.object({ export const whmcsProductListResponseSchema = z.object({

View File

@ -15,4 +15,3 @@ export const Salesforce = {
export { SalesforceMapper, SalesforceRaw }; export { SalesforceMapper, SalesforceRaw };
export * from "./salesforce/mapper.js"; export * from "./salesforce/mapper.js";
export * from "./salesforce/raw.types.js"; export * from "./salesforce/raw.types.js";

View File

@ -4,4 +4,3 @@
export * from "./raw.types.js"; export * from "./raw.types.js";
export * from "./mapper.js"; export * from "./mapper.js";

View File

@ -46,9 +46,7 @@ function nowIsoString(): string {
* @param record - Raw Salesforce Case record from SOQL query * @param record - Raw Salesforce Case record from SOQL query
* @returns Validated SupportCase domain object * @returns Validated SupportCase domain object
*/ */
export function transformSalesforceCaseToSupportCase( export function transformSalesforceCaseToSupportCase(record: SalesforceCaseRecord): SupportCase {
record: SalesforceCaseRecord
): SupportCase {
// Get raw values // Get raw values
const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW; const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW;
const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM; const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
@ -167,4 +165,3 @@ export function buildCaseByIdQuery(
LIMIT 1 LIMIT 1
`.trim(); `.trim();
} }

View File

@ -319,4 +319,3 @@ const PRIORITY_TO_SALESFORCE: Record<string, string> = {
export function toSalesforcePriority(displayPriority: string): string { export function toSalesforcePriority(displayPriority: string): string {
return PRIORITY_TO_SALESFORCE[displayPriority] ?? SALESFORCE_CASE_PRIORITY.MEDIUM; return PRIORITY_TO_SALESFORCE[displayPriority] ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
} }

View File

@ -1,9 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY, SUPPORT_CASE_CATEGORY } from "./contract.js";
SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_CATEGORY,
} from "./contract.js";
/** /**
* Portal status values (mapped from Salesforce Japanese API names) * Portal status values (mapped from Salesforce Japanese API names)

View File

@ -5,6 +5,7 @@ Utility functions and helpers for the domain layer. This package contains **util
## Purpose ## Purpose
The toolkit provides pure utility functions for common operations like: The toolkit provides pure utility functions for common operations like:
- String manipulation - String manipulation
- Date/time formatting - Date/time formatting
- Currency formatting - Currency formatting
@ -43,10 +44,10 @@ Validation determines if input is valid or invalid:
```typescript ```typescript
// These are VALIDATION functions → Use common/validation.ts // These are VALIDATION functions → Use common/validation.ts
isValidEmail(email) // Returns true/false isValidEmail(email); // Returns true/false
isValidUuid(id) // Returns true/false isValidUuid(id); // Returns true/false
isValidUrl(url) // Returns true/false isValidUrl(url); // Returns true/false
validateUrlOrThrow(url) // Throws if invalid validateUrlOrThrow(url); // Throws if invalid
``` ```
**Location**: `packages/domain/common/validation.ts` **Location**: `packages/domain/common/validation.ts`
@ -57,10 +58,10 @@ Utilities transform, extract, or manipulate data:
```typescript ```typescript
// These are UTILITY functions → Use toolkit // These are UTILITY functions → Use toolkit
getEmailDomain(email) // Extracts domain from email getEmailDomain(email); // Extracts domain from email
normalizeEmail(email) // Lowercases and trims normalizeEmail(email); // Lowercases and trims
ensureProtocol(url) // Adds https:// if missing ensureProtocol(url); // Adds https:// if missing
getHostname(url) // Extracts hostname getHostname(url); // Extracts hostname
``` ```
**Location**: `packages/domain/toolkit/` **Location**: `packages/domain/toolkit/`
@ -72,8 +73,8 @@ getHostname(url) // Extracts hostname
```typescript ```typescript
import { formatCurrency, formatDate } from "@customer-portal/domain/toolkit/formatting"; import { formatCurrency, formatDate } from "@customer-portal/domain/toolkit/formatting";
const price = formatCurrency(1000, "JPY"); // "¥1,000" const price = formatCurrency(1000, "JPY"); // "¥1,000"
const date = formatDate(new Date()); // "2025-10-08" const date = formatDate(new Date()); // "2025-10-08"
``` ```
### Type Guards ### Type Guards
@ -92,8 +93,8 @@ if (isString(value)) {
```typescript ```typescript
import { ensureProtocol, getHostname } from "@customer-portal/domain/toolkit/validation/url"; import { ensureProtocol, getHostname } from "@customer-portal/domain/toolkit/validation/url";
const fullUrl = ensureProtocol("example.com"); // "https://example.com" const fullUrl = ensureProtocol("example.com"); // "https://example.com"
const host = getHostname("https://example.com/path"); // "example.com" const host = getHostname("https://example.com/path"); // "example.com"
``` ```
### Email Utilities ### Email Utilities
@ -101,55 +102,58 @@ const host = getHostname("https://example.com/path"); // "example.com"
```typescript ```typescript
import { getEmailDomain, normalizeEmail } from "@customer-portal/domain/toolkit/validation/email"; import { getEmailDomain, normalizeEmail } from "@customer-portal/domain/toolkit/validation/email";
const domain = getEmailDomain("user@example.com"); // "example.com" const domain = getEmailDomain("user@example.com"); // "example.com"
const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com" const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
``` ```
## When to Use What ## When to Use What
| Task | Use | Example | | Task | Use | Example |
|------|-----|---------| | --------------------- | -------------------------------- | ------------------------------- |
| Validate email format | `common/validation.ts` | `isValidEmail(email)` | | Validate email format | `common/validation.ts` | `isValidEmail(email)` |
| Extract email domain | `toolkit/validation/email.ts` | `getEmailDomain(email)` | | Extract email domain | `toolkit/validation/email.ts` | `getEmailDomain(email)` |
| Validate URL format | `common/validation.ts` | `isValidUrl(url)` | | Validate URL format | `common/validation.ts` | `isValidUrl(url)` |
| Add protocol to URL | `toolkit/validation/url.ts` | `ensureProtocol(url)` | | Add protocol to URL | `toolkit/validation/url.ts` | `ensureProtocol(url)` |
| Format currency | `toolkit/formatting/currency.ts` | `formatCurrency(amount, "JPY")` | | Format currency | `toolkit/formatting/currency.ts` | `formatCurrency(amount, "JPY")` |
| Format date | `toolkit/formatting/date.ts` | `formatDate(date)` | | Format date | `toolkit/formatting/date.ts` | `formatDate(date)` |
## Best Practices ## Best Practices
1. **Use validation functions for validation** 1. **Use validation functions for validation**
```typescript ```typescript
// ✅ Good // ✅ Good
import { isValidEmail } from "@customer-portal/domain/common/validation"; import { isValidEmail } from "@customer-portal/domain/common/validation";
if (!isValidEmail(email)) throw new Error("Invalid email"); if (!isValidEmail(email)) throw new Error("Invalid email");
// ❌ Bad - don't write custom validation // ❌ Bad - don't write custom validation
if (!email.includes("@")) throw new Error("Invalid email"); if (!email.includes("@")) throw new Error("Invalid email");
``` ```
2. **Use utility functions for transformations** 2. **Use utility functions for transformations**
```typescript ```typescript
// ✅ Good // ✅ Good
import { normalizeEmail } from "@customer-portal/domain/toolkit/validation/email"; import { normalizeEmail } from "@customer-portal/domain/toolkit/validation/email";
const clean = normalizeEmail(email); const clean = normalizeEmail(email);
// ❌ Bad - don't duplicate utility logic // ❌ Bad - don't duplicate utility logic
const clean = email.trim().toLowerCase(); const clean = email.trim().toLowerCase();
``` ```
3. **Don't mix validation and utilities** 3. **Don't mix validation and utilities**
```typescript ```typescript
// ❌ Bad - mixing concerns // ❌ Bad - mixing concerns
function processEmail(email: string) { function processEmail(email: string) {
if (!email.includes("@")) return null; // Validation if (!email.includes("@")) return null; // Validation
return email.toLowerCase(); // Utility return email.toLowerCase(); // Utility
} }
// ✅ Good - separate concerns // ✅ Good - separate concerns
import { isValidEmail } from "@customer-portal/domain/common/validation"; import { isValidEmail } from "@customer-portal/domain/common/validation";
import { normalizeEmail } from "@customer-portal/domain/toolkit/validation/email"; import { normalizeEmail } from "@customer-portal/domain/toolkit/validation/email";
function processEmail(email: string) { function processEmail(email: string) {
if (!isValidEmail(email)) return null; if (!isValidEmail(email)) return null;
return normalizeEmail(email); return normalizeEmail(email);
@ -181,4 +185,3 @@ If you're unsure whether something belongs in toolkit or common/validation:
- **Ask**: "Does this transform or extract data?" - **Ask**: "Does this transform or extract data?"
- YES → It's a utility → Use `toolkit/` - YES → It's a utility → Use `toolkit/`
- NO → Might be validation → Use `common/validation.ts` - NO → Might be validation → Use `common/validation.ts`

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Date Formatting * Toolkit - Date Formatting
* *
* Utilities for formatting dates and times. * Utilities for formatting dates and times.
*/ */
@ -17,10 +17,7 @@ export interface DateFormatOptions {
/** /**
* Format an ISO date string for display * Format an ISO date string for display
*/ */
export function formatDate( export function formatDate(isoString: string, options: DateFormatOptions = {}): string {
isoString: string,
options: DateFormatOptions = {}
): string {
const { const {
locale = "en-US", locale = "en-US",
dateStyle = "medium", dateStyle = "medium",
@ -31,7 +28,7 @@ export function formatDate(
try { try {
const date = new Date(isoString); const date = new Date(isoString);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return isoString; // Return original if invalid return isoString; // Return original if invalid
} }
@ -51,10 +48,7 @@ export function formatDate(
/** /**
* Format a date relative to now (e.g., "2 days ago", "in 3 hours") * Format a date relative to now (e.g., "2 days ago", "in 3 hours")
*/ */
export function formatRelativeDate( export function formatRelativeDate(isoString: string, options: { locale?: string } = {}): string {
isoString: string,
options: { locale?: string } = {}
): string {
const { locale = "en-US" } = options; const { locale = "en-US" } = options;
try { try {
@ -90,4 +84,3 @@ export function isValidDate(dateString: string): boolean {
const date = new Date(dateString); const date = new Date(dateString);
return !isNaN(date.getTime()); return !isNaN(date.getTime());
} }

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Formatting * Toolkit - Formatting
* *
* Formatting utilities for currency, dates, phone numbers, etc. * Formatting utilities for currency, dates, phone numbers, etc.
*/ */
@ -8,4 +8,3 @@ export * from "./currency.js";
export * from "./date.js"; export * from "./date.js";
export * from "./phone.js"; export * from "./phone.js";
export * from "./text.js"; export * from "./text.js";

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Phone Number Formatting * Toolkit - Phone Number Formatting
* *
* Utilities for formatting phone numbers. * Utilities for formatting phone numbers.
*/ */
@ -37,12 +37,12 @@ export function formatPhoneNumber(phone: string): string {
*/ */
export function normalizePhoneNumber(phone: string, defaultCountryCode = "1"): string { export function normalizePhoneNumber(phone: string, defaultCountryCode = "1"): string {
const digits = phone.replace(/\D/g, ""); const digits = phone.replace(/\D/g, "");
// If already has country code, return with + // If already has country code, return with +
if (digits.length >= 10 && !digits.startsWith(defaultCountryCode)) { if (digits.length >= 10 && !digits.startsWith(defaultCountryCode)) {
return `+${digits}`; return `+${digits}`;
} }
// Add default country code // Add default country code
return `+${defaultCountryCode}${digits}`; return `+${defaultCountryCode}${digits}`;
} }
@ -56,7 +56,7 @@ export function formatPhoneForWhmcs(phone: string): string {
// Remove all non-digit characters except leading + // Remove all non-digit characters except leading +
const hasPlus = phone.startsWith("+"); const hasPlus = phone.startsWith("+");
const digits = phone.replace(/\D/g, ""); const digits = phone.replace(/\D/g, "");
if (digits.length === 0) { if (digits.length === 0) {
return phone; return phone;
} }
@ -65,7 +65,7 @@ export function formatPhoneForWhmcs(phone: string): string {
if (digits.startsWith("81") && digits.length >= 11) { if (digits.startsWith("81") && digits.length >= 11) {
return `+81.${digits.slice(2)}`; return `+81.${digits.slice(2)}`;
} }
// For US/Canada numbers (10 digits or 11 starting with 1) // For US/Canada numbers (10 digits or 11 starting with 1)
if (digits.length === 10) { if (digits.length === 10) {
return `+1.${digits}`; return `+1.${digits}`;
@ -88,4 +88,3 @@ export function formatPhoneForWhmcs(phone: string): string {
// Return with + prefix if it had one, otherwise as-is // Return with + prefix if it had one, otherwise as-is
return hasPlus ? `+${digits}` : digits; return hasPlus ? `+${digits}` : digits;
} }

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Text Formatting * Toolkit - Text Formatting
* *
* Utilities for text manipulation and formatting. * Utilities for text manipulation and formatting.
*/ */
@ -56,12 +56,11 @@ export function maskString(str: string, visibleStart = 3, visibleEnd = 3, maskCh
if (str.length <= visibleStart + visibleEnd) { if (str.length <= visibleStart + visibleEnd) {
return str; return str;
} }
const start = str.slice(0, visibleStart); const start = str.slice(0, visibleStart);
const end = str.slice(-visibleEnd); const end = str.slice(-visibleEnd);
const maskedLength = str.length - visibleStart - visibleEnd; const maskedLength = str.length - visibleStart - visibleEnd;
const masked = maskChar.repeat(maskedLength); const masked = maskChar.repeat(maskedLength);
return `${start}${masked}${end}`; return `${start}${masked}${end}`;
} }

View File

@ -1,6 +1,6 @@
/** /**
* Domain Toolkit * Domain Toolkit
* *
* Utility functions and helpers used across all domain packages. * Utility functions and helpers used across all domain packages.
*/ */
@ -24,4 +24,3 @@ export {
isSuccess, isSuccess,
isError, isError,
} from "./typing/helpers.js"; } from "./typing/helpers.js";

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Type Assertions * Toolkit - Type Assertions
* *
* Runtime assertion utilities for type safety. * Runtime assertion utilities for type safety.
*/ */
@ -62,4 +62,3 @@ export function assertNumber(
export function assertNever(value: never, message = "Unexpected value"): never { export function assertNever(value: never, message = "Unexpected value"): never {
throw new AssertionError(`${message}: ${JSON.stringify(value)}`); throw new AssertionError(`${message}: ${JSON.stringify(value)}`);
} }

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Type Guards * Toolkit - Type Guards
* *
* Type guard utilities for runtime type checking. * Type guard utilities for runtime type checking.
*/ */
@ -59,4 +59,3 @@ export function isDefined<T>(value: T | null | undefined): value is T {
export function filterDefined<T>(arr: (T | null | undefined)[]): T[] { export function filterDefined<T>(arr: (T | null | undefined)[]): T[] {
return arr.filter(isDefined); return arr.filter(isDefined);
} }

View File

@ -1,10 +1,9 @@
/** /**
* Toolkit - Typing * Toolkit - Typing
* *
* TypeScript type utilities and runtime type checking. * TypeScript type utilities and runtime type checking.
*/ */
export * from "./guards.js"; export * from "./guards.js";
export * from "./assertions.js"; export * from "./assertions.js";
export * from "./helpers.js"; export * from "./helpers.js";

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Email Utilities * Toolkit - Email Utilities
* *
* Email utility functions (extraction, normalization). * Email utility functions (extraction, normalization).
* For email validation, use functions from common/validation.ts * For email validation, use functions from common/validation.ts
*/ */
@ -19,4 +19,3 @@ export function getEmailDomain(email: string): string | null {
export function normalizeEmail(email: string): string { export function normalizeEmail(email: string): string {
return email.trim().toLowerCase(); return email.trim().toLowerCase();
} }

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - Validation * Toolkit - Validation
* *
* Validation utilities for common data types. * Validation utilities for common data types.
*/ */
@ -8,5 +8,3 @@ export * from "./email.js";
export * from "./url.js"; export * from "./url.js";
export * from "./string.js"; export * from "./string.js";
export * from "./helpers.js"; export * from "./helpers.js";

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - String Validation * Toolkit - String Validation
* *
* String validation utilities. * String validation utilities.
*/ */
@ -45,4 +45,3 @@ export function isAlpha(str: string): boolean {
export function isNumeric(str: string): boolean { export function isNumeric(str: string): boolean {
return /^\d+$/.test(str); return /^\d+$/.test(str);
} }

View File

@ -1,6 +1,6 @@
/** /**
* Toolkit - URL Utilities * Toolkit - URL Utilities
* *
* URL parsing and manipulation utilities. * URL parsing and manipulation utilities.
* For URL validation, use functions from common/validation.ts * For URL validation, use functions from common/validation.ts
*/ */
@ -26,4 +26,3 @@ export function getHostname(url: string): string | null {
return null; return null;
} }
} }

15
pnpm-lock.yaml generated
View File

@ -241,6 +241,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.17 specifier: ^4.1.17
version: 4.1.17 version: 4.1.17
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.17)
typescript: typescript:
specifier: "catalog:" specifier: "catalog:"
version: 5.9.3 version: 5.9.3
@ -6874,6 +6877,14 @@ packages:
integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==, integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==,
} }
tailwindcss-animate@1.0.7:
resolution:
{
integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==,
}
peerDependencies:
tailwindcss: ">=3.0.0 || insiders"
tailwindcss@4.1.17: tailwindcss@4.1.17:
resolution: resolution:
{ {
@ -11504,6 +11515,10 @@ snapshots:
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.17):
dependencies:
tailwindcss: 4.1.17
tailwindcss@4.1.17: {} tailwindcss@4.1.17: {}
tapable@2.3.0: {} tapable@2.3.0: {}