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);
``` ```

View File

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

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;
@ -66,4 +63,3 @@ export function useSimTopUpPricing(): UseSimTopUpPricingResult {
return { pricing, loading, error, calculatePreview }; return { pricing, loading, error, calculatePreview };
} }

View File

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

View File

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

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
@ -108,13 +112,15 @@ 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
@ -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 |
--- ---
@ -285,7 +288,7 @@ 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

@ -35,5 +35,4 @@ export type {
BillingSummary, BillingSummary,
InvoiceQueryParams, InvoiceQueryParams,
InvoiceListQuery, InvoiceListQuery,
} from './schema.js'; } from "./schema.js";

View File

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

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

@ -274,13 +274,21 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
* 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

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

@ -27,7 +27,7 @@ type SalesforceResponseBase = z.infer<typeof salesforceResponseBaseSchema>;
* *
* 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

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

@ -33,7 +33,10 @@ export const customerNumberSchema = z.string().min(1, "Customer number is requir
* @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);
} }

View File

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

View File

@ -28,4 +28,3 @@ export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth {
updatedAt: raw.updatedAt.toISOString(), updatedAt: raw.updatedAt.toISOString(),
}); });
} }

View File

@ -24,4 +24,3 @@ export interface PrismaUserRaw {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

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

@ -76,7 +76,9 @@ export function validateNoConflicts(
* 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"
@ -130,4 +130,3 @@ export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMapp
return sanitized; return sanitized;
} }

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

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

@ -69,9 +69,7 @@ export function prepareOrderFromCart(
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

@ -53,4 +53,4 @@ export type {
PaymentGatewayType, PaymentGatewayType,
PaymentGateway, PaymentGateway,
PaymentGatewayList, PaymentGatewayList,
} from './schema.js'; } from "./schema.js";

View File

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

@ -64,4 +64,4 @@ export type {
SimOrderActivationRequest, SimOrderActivationRequest,
SimOrderActivationMnp, SimOrderActivationMnp,
SimOrderActivationAddons, SimOrderActivationAddons,
} from './schema.js'; } from "./schema.js";

View File

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

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

@ -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}`;
} }
@ -43,4 +43,3 @@ export function parseDateFromApi(dateString: string): Date | null {
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

@ -45,4 +45,4 @@ export type {
SubscriptionList, SubscriptionList,
SubscriptionQueryParams, SubscriptionQueryParams,
SubscriptionQuery, SubscriptionQuery,
} from './schema.js'; } from "./schema.js";

View File

@ -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
@ -148,12 +142,14 @@ export const whmcsProductRawSchema = z.object({
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,24 +102,25 @@ 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";
@ -129,6 +131,7 @@ const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
``` ```
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";
@ -139,11 +142,12 @@ const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
``` ```
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
@ -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

@ -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",
@ -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

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

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

@ -64,4 +64,3 @@ export function maskString(str: string, visibleStart = 3, visibleEnd = 3, maskCh
return `${start}${masked}${end}`; return `${start}${masked}${end}`;
} }

View File

@ -24,4 +24,3 @@ export {
isSuccess, isSuccess,
isError, isError,
} from "./typing/helpers.js"; } from "./typing/helpers.js";

View File

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

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

@ -7,4 +7,3 @@
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

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

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

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

@ -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: {}