commit
b8da286fce
@ -1,4 +1,5 @@
|
||||
<!-- 67f8fea5-b6cb-4187-8097-25ccb37e1dcf fa268fdd-dd67-4003-bb94-8236ed95ab44 -->
|
||||
|
||||
# Domain & BFF Clean Architecture Refactoring
|
||||
|
||||
## Overview
|
||||
@ -173,7 +174,7 @@ const result = this.orderWhmcsMapper.mapOrderItemsToWhmcs(items);
|
||||
With:
|
||||
|
||||
```typescript
|
||||
import { Providers } from '@customer-portal/domain/orders';
|
||||
import { Providers } from "@customer-portal/domain/orders";
|
||||
const result = Providers.Whmcs.mapFulfillmentOrderItems(items);
|
||||
```
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"pnpm install"
|
||||
]
|
||||
"setup-worktree": ["pnpm install"]
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
|
||||
## Directory Structure
|
||||
|
||||
### Development (Monorepo)
|
||||
|
||||
```
|
||||
/project-root/
|
||||
├── apps/bff/
|
||||
@ -24,6 +25,7 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
|
||||
```
|
||||
|
||||
### Production (Docker Container)
|
||||
|
||||
```
|
||||
/app/
|
||||
├── prisma/
|
||||
@ -37,7 +39,7 @@ Prisma embeds the schema path into the generated client. We regenerate the clien
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| `pnpm db:generate` | Regenerate Prisma client (`--schema=prisma/schema.prisma`) |
|
||||
| `pnpm db:migrate` | Run migrations (dev) with explicit schema path |
|
||||
| `pnpm db:studio` | Open Prisma Studio with explicit schema path |
|
||||
|
||||
@ -19,4 +19,3 @@ export default defineConfig({
|
||||
url: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
export { RateLimitModule } from "./rate-limit.module.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";
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
export const SkipRateLimit = () => SetMetadata(RATE_LIMIT_KEY, { skip: true } as RateLimitOptions);
|
||||
|
||||
|
||||
@ -124,7 +124,10 @@ export class RateLimitGuard implements CanActivate {
|
||||
/**
|
||||
* 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 controllerName = context.getClass().name;
|
||||
const cacheKey = `${controllerName}:${handlerName}:${options.limit}:${options.ttl}`;
|
||||
@ -163,4 +166,3 @@ export class RateLimitGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,4 +26,3 @@ import { RateLimitGuard } from "./rate-limit.guard.js";
|
||||
exports: [RateLimitGuard],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
|
||||
|
||||
@ -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 { Logger } from "nestjs-pino";
|
||||
import type { Request } from "express";
|
||||
|
||||
@ -47,8 +47,6 @@ export class BaseServicesService {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Query failed: ${context}`, {
|
||||
error: getErrorMessage(error),
|
||||
soql,
|
||||
context,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -67,11 +67,7 @@ export class SimServicesService extends BaseServicesService {
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildProductQuery("SIM", "Activation", [
|
||||
"Catalog_Order__c",
|
||||
"Auto_Add__c",
|
||||
"Is_Default__c",
|
||||
]);
|
||||
const soql = this.buildProductQuery("SIM", "Activation", ["Catalog_Order__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"SIM Activation Fees"
|
||||
|
||||
@ -55,7 +55,10 @@ export class VpnServicesService extends BaseServicesService {
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
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>(
|
||||
soql,
|
||||
"VPN Activation Fees"
|
||||
|
||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"predev": "node ./scripts/dev-prep.mjs",
|
||||
"dev": "next dev -p ${NEXT_PORT:-3000}",
|
||||
"build": "next build --webpack",
|
||||
"build": "next build",
|
||||
"build:turbo": "next build",
|
||||
"build:analyze": "ANALYZE=true next build",
|
||||
"analyze": "pnpm run build:analyze",
|
||||
@ -40,6 +40,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
/* Tailwind CSS v4 */
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
@import "../styles/tokens.css";
|
||||
@import "../styles/utilities.css";
|
||||
@import "../styles/responsive.css";
|
||||
|
||||
@ -17,8 +17,7 @@ const errorMessageVariants = cva("flex items-center gap-1 text-sm", {
|
||||
});
|
||||
|
||||
interface ErrorMessageProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement>,
|
||||
VariantProps<typeof errorMessageVariants> {
|
||||
extends React.HTMLAttributes<HTMLParagraphElement>, VariantProps<typeof errorMessageVariants> {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export { AgentforceWidget } from "./AgentforceWidget";
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ export const AuroraBackground = ({
|
||||
<div
|
||||
className={cn(
|
||||
"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}
|
||||
>
|
||||
@ -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)]`,
|
||||
|
||||
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>
|
||||
|
||||
@ -2,4 +2,3 @@ export { AccountStep } from "./AccountStep";
|
||||
export { AddressStep } from "./AddressStep";
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
export { ReviewStep } from "./ReviewStep";
|
||||
|
||||
|
||||
@ -5,10 +5,7 @@
|
||||
|
||||
import type { OrderDisplayItem } from "@customer-portal/domain/orders";
|
||||
|
||||
export {
|
||||
buildOrderDisplayItems,
|
||||
categorizeOrderItem,
|
||||
} from "@customer-portal/domain/orders";
|
||||
export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders";
|
||||
|
||||
export type {
|
||||
OrderDisplayItem,
|
||||
@ -20,10 +17,7 @@ export type {
|
||||
/**
|
||||
* Summarize order display items for compact display
|
||||
*/
|
||||
export function summarizeOrderDisplayItems(
|
||||
items: OrderDisplayItem[],
|
||||
fallback: string
|
||||
): string {
|
||||
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
|
||||
if (items.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ export function AddressConfirmation({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]);
|
||||
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed, setAddressConfirmed]);
|
||||
|
||||
useEffect(() => {
|
||||
log.info("Address confirmation component mounted");
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
SimTopUpPricing,
|
||||
SimTopUpPricingPreviewResponse,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import type { SimTopUpPricing, SimTopUpPricingPreviewResponse } from "@customer-portal/domain/sim";
|
||||
|
||||
interface UseSimTopUpPricingResult {
|
||||
pricing: SimTopUpPricing | null;
|
||||
@ -66,4 +63,3 @@ export function useSimTopUpPricing(): UseSimTopUpPricingResult {
|
||||
|
||||
return { pricing, loading, error, calculatePreview };
|
||||
}
|
||||
|
||||
|
||||
@ -25,4 +25,3 @@ export function useCreateCase() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "./case-presenters";
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# Complete Portainer Guide for Customer Portal
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Creating a Stack in Portainer](#creating-a-stack-in-portainer)
|
||||
2. [Repository vs Upload vs Web Editor](#stack-creation-methods)
|
||||
3. [Security Concerns & Best Practices](#security-concerns)
|
||||
@ -22,6 +23,7 @@
|
||||
Click **"+ Add stack"** button
|
||||
|
||||
You'll see three creation methods:
|
||||
|
||||
- **Web editor** - Paste compose file directly
|
||||
- **Upload** - Upload a compose file
|
||||
- **Repository** - Pull from Git repository
|
||||
@ -45,16 +47,19 @@ Click **"Deploy the stack"**
|
||||
### Method 1: Web Editor (Simplest)
|
||||
|
||||
**How:**
|
||||
|
||||
1. Select "Web editor"
|
||||
2. Paste your `docker-compose.yml` content
|
||||
3. Add environment variables manually or load from file
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Quick and simple
|
||||
- ✅ No external dependencies
|
||||
- ✅ Full control over content
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Manual updates required
|
||||
- ❌ No version control
|
||||
- ❌ Easy to make mistakes when editing
|
||||
@ -66,17 +71,20 @@ Click **"Deploy the stack"**
|
||||
### Method 2: Upload (Recommended for Your Case)
|
||||
|
||||
**How:**
|
||||
|
||||
1. Select "Upload"
|
||||
2. Upload your `docker-compose.yml` file
|
||||
3. Optionally upload a `.env` file for environment variables
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Version control on your local machine
|
||||
- ✅ Can prepare and test locally
|
||||
- ✅ No external network dependencies
|
||||
- ✅ Works in air-gapped environments
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Manual upload for each update
|
||||
- ❌ Need to manage files locally
|
||||
|
||||
@ -87,12 +95,14 @@ Click **"Deploy the stack"**
|
||||
### Method 3: Repository (Git Integration)
|
||||
|
||||
**How:**
|
||||
|
||||
1. Select "Repository"
|
||||
2. Enter repository URL (GitHub, GitLab, Bitbucket, etc.)
|
||||
3. Specify branch and compose file path
|
||||
4. Add authentication if private repo
|
||||
|
||||
**Example Configuration:**
|
||||
|
||||
```
|
||||
Repository URL: https://github.com/your-org/customer-portal
|
||||
Reference: main
|
||||
@ -100,16 +110,19 @@ Compose path: docker/portainer/docker-compose.yml
|
||||
```
|
||||
|
||||
**For Private Repos:**
|
||||
|
||||
- Use a Personal Access Token (PAT) as password
|
||||
- Or use deploy keys
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Version controlled
|
||||
- ✅ Easy to update (just click "Pull and redeploy")
|
||||
- ✅ Team can review changes via PR
|
||||
- ✅ Audit trail of changes
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Requires network access to repo
|
||||
- ❌ Secrets in repo = security risk
|
||||
- ❌ Need to manage repo access tokens
|
||||
@ -124,6 +137,7 @@ Compose path: docker/portainer/docker-compose.yml
|
||||
**Use: Upload + Environment Variables in Portainer UI**
|
||||
|
||||
Why:
|
||||
|
||||
1. Your compose file rarely changes (it's just orchestration)
|
||||
2. Sensitive data stays in Portainer, not in Git
|
||||
3. Image updates are done via environment variables
|
||||
@ -136,6 +150,7 @@ Why:
|
||||
### 🔴 Critical Security Issues
|
||||
|
||||
#### 1. Never Store Secrets in Git
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - Secrets in compose file
|
||||
environment:
|
||||
@ -149,6 +164,7 @@ environment:
|
||||
```
|
||||
|
||||
#### 2. Never Store Secrets in Docker Images
|
||||
|
||||
```dockerfile
|
||||
# ❌ BAD - Secrets baked into image
|
||||
ENV JWT_SECRET="my-secret"
|
||||
@ -159,6 +175,7 @@ COPY secrets/ /app/secrets/
|
||||
```
|
||||
|
||||
#### 3. Portainer Access Control
|
||||
|
||||
```
|
||||
⚠️ Portainer has full Docker access = root on the host
|
||||
|
||||
@ -173,6 +190,7 @@ Best practices:
|
||||
### 🟡 Medium Security Concerns
|
||||
|
||||
#### 4. Environment Variables in Portainer
|
||||
|
||||
```
|
||||
Portainer stores env vars in its database.
|
||||
This is generally safe, but consider:
|
||||
@ -188,6 +206,7 @@ Mitigation:
|
||||
```
|
||||
|
||||
#### 5. Image Trust
|
||||
|
||||
```
|
||||
⚠️ You're loading .tar files - verify their integrity
|
||||
|
||||
@ -198,6 +217,7 @@ Best practice:
|
||||
```
|
||||
|
||||
Add to build script:
|
||||
|
||||
```bash
|
||||
# Generate checksums
|
||||
sha256sum portal-frontend.latest.tar > portal-frontend.latest.tar.sha256
|
||||
@ -209,6 +229,7 @@ sha256sum -c portal-backend.latest.tar.sha256
|
||||
```
|
||||
|
||||
#### 6. Network Exposure
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - Database exposed to host
|
||||
database:
|
||||
@ -225,6 +246,7 @@ database:
|
||||
### 🟢 Good Security Practices (Already in Place)
|
||||
|
||||
Your current setup does these right:
|
||||
|
||||
- ✅ Non-root users in containers
|
||||
- ✅ Health checks configured
|
||||
- ✅ Database/Redis not exposed externally
|
||||
@ -252,12 +274,14 @@ watchtower:
|
||||
```
|
||||
|
||||
**Why NOT recommended:**
|
||||
|
||||
- ❌ No control over when updates happen
|
||||
- ❌ No rollback mechanism
|
||||
- ❌ Can break production unexpectedly
|
||||
- ❌ Requires images in a registry (not .tar files)
|
||||
|
||||
We've disabled Watchtower in your compose:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
@ -270,27 +294,32 @@ labels:
|
||||
Portainer can expose a webhook URL that triggers stack redeployment.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Go to Stack → Settings
|
||||
2. Enable "Webhook"
|
||||
3. Copy the webhook URL
|
||||
|
||||
**Trigger from CI/CD:**
|
||||
|
||||
```bash
|
||||
# In your GitHub Actions / GitLab CI
|
||||
curl -X POST "https://your-portainer:9443/api/stacks/webhook/abc123"
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```
|
||||
Build Images → Push to Registry → Trigger Webhook → Portainer Redeploys
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Controlled updates
|
||||
- ✅ Integrated with CI/CD
|
||||
- ✅ Can add approval gates
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Requires images in a registry
|
||||
- ❌ Webhook URL is a secret
|
||||
- ❌ 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:**
|
||||
|
||||
```bash
|
||||
# deploy.sh - Run locally
|
||||
#!/bin/bash
|
||||
@ -339,11 +369,13 @@ echo "✅ Deployed ${TAG}"
|
||||
If you want auto-updates, use a registry:
|
||||
|
||||
**Free Options:**
|
||||
|
||||
- GitHub Container Registry (ghcr.io) - free for public repos
|
||||
- GitLab Container Registry - free
|
||||
- Docker Hub - 1 private repo free
|
||||
|
||||
**Setup:**
|
||||
|
||||
```bash
|
||||
# Build and push
|
||||
./scripts/plesk/build-images.sh --tag v1.2.3 --push ghcr.io/your-org
|
||||
@ -377,6 +409,7 @@ services:
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Build: `./scripts/plesk/build-images.sh --tag 20241201-abc`
|
||||
2. Upload: `scp *.tar server:/path/images/`
|
||||
3. Load: `docker load -i *.tar`
|
||||
@ -404,8 +437,9 @@ services:
|
||||
## Quick Reference: Portainer Stack Commands
|
||||
|
||||
### Via Portainer UI
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| ------------ | -------------------------------------------------- |
|
||||
| Create stack | Stacks → Add stack → Configure → Deploy |
|
||||
| Update stack | Stacks → Select → Editor → Update |
|
||||
| Change image | Stacks → Select → Env vars → Change IMAGE → Update |
|
||||
@ -415,6 +449,7 @@ services:
|
||||
| Delete | Stacks → Select → Delete |
|
||||
|
||||
### Via CLI (on server)
|
||||
|
||||
```bash
|
||||
# Navigate to stack directory
|
||||
cd /path/to/portal
|
||||
@ -443,10 +478,9 @@ docker compose --env-file stack.env down -v
|
||||
## Summary
|
||||
|
||||
| Aspect | Recommendation |
|
||||
|--------|---------------|
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| Stack creation | **Upload** method (version control locally, no secrets in git) |
|
||||
| Secrets management | **Portainer env vars** or **mounted secrets volume** |
|
||||
| Image updates | **Manual script** for now, migrate to **registry + webhook** later |
|
||||
| Auto-updates | **Not recommended** for production; use controlled deployments |
|
||||
| Rollback | Keep previous image tags, update env vars to rollback |
|
||||
|
||||
|
||||
@ -39,4 +39,3 @@ volumes:
|
||||
networks:
|
||||
default:
|
||||
name: portal_dev
|
||||
|
||||
|
||||
@ -71,7 +71,10 @@ export default [
|
||||
files: [...BFF_TS_FILES, "packages/domain/**/*.ts"],
|
||||
rules: {
|
||||
"@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"] }],
|
||||
},
|
||||
},
|
||||
|
||||
@ -72,18 +72,22 @@ packages/domain/
|
||||
## 🎯 Design Principles
|
||||
|
||||
### 1. **Domain-First Organization**
|
||||
|
||||
Each business domain owns its:
|
||||
|
||||
- **`contract.ts`** - TypeScript interfaces (provider-agnostic)
|
||||
- **`schema.ts`** - Zod validation schemas (runtime safety)
|
||||
- **`providers/`** - Provider-specific adapters (WHMCS, Salesforce, Freebit)
|
||||
|
||||
### 2. **Single Source of Truth**
|
||||
|
||||
- ✅ All types defined in domain package
|
||||
- ✅ All validation schemas in domain package
|
||||
- ✅ No duplicate type definitions in apps
|
||||
- ✅ Shared between frontend (Next.js) and backend (NestJS)
|
||||
|
||||
### 3. **Type Safety + Runtime Validation**
|
||||
|
||||
- TypeScript provides compile-time type checking
|
||||
- Zod schemas provide runtime validation
|
||||
- Use `z.infer<typeof schema>` to derive types from schemas
|
||||
@ -108,13 +112,15 @@ import {
|
||||
ApiResponse,
|
||||
ApiSuccessResponse,
|
||||
ApiErrorResponse,
|
||||
apiResponseSchema
|
||||
apiResponseSchema,
|
||||
} from "@customer-portal/domain/common";
|
||||
|
||||
// Type-safe API responses
|
||||
const response: ApiResponse<Invoice> = {
|
||||
success: true,
|
||||
data: { /* invoice data */ }
|
||||
data: {
|
||||
/* invoice data */
|
||||
},
|
||||
};
|
||||
|
||||
// With validation
|
||||
@ -172,10 +178,7 @@ function LoginForm() {
|
||||
|
||||
```typescript
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
import {
|
||||
createOrderRequestSchema,
|
||||
type CreateOrderRequest
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain/orders";
|
||||
|
||||
@Controller("orders")
|
||||
export class OrdersController {
|
||||
@ -195,7 +198,7 @@ export class OrdersController {
|
||||
### **API Responses**
|
||||
|
||||
| Schema | Description |
|
||||
|--------|-------------|
|
||||
| -------------------------------------- | ------------------------------------ |
|
||||
| `apiSuccessResponseSchema(dataSchema)` | Successful API response wrapper |
|
||||
| `apiErrorResponseSchema` | Error API response with code/message |
|
||||
| `apiResponseSchema(dataSchema)` | Discriminated union of success/error |
|
||||
@ -203,7 +206,7 @@ export class OrdersController {
|
||||
### **Pagination & Queries**
|
||||
|
||||
| Schema | Description |
|
||||
|--------|-------------|
|
||||
| ------------------------------------- | ------------------------------ |
|
||||
| `paginationParamsSchema` | Page, limit, offset parameters |
|
||||
| `paginatedResponseSchema(itemSchema)` | Paginated list response |
|
||||
| `filterParamsSchema` | Search, sortBy, sortOrder |
|
||||
@ -212,7 +215,7 @@ export class OrdersController {
|
||||
### **Domain-Specific Query Params**
|
||||
|
||||
| Schema | Description |
|
||||
|--------|-------------|
|
||||
| ------------------------------- | -------------------------------------- |
|
||||
| `invoiceQueryParamsSchema` | Invoice list filtering (status, dates) |
|
||||
| `subscriptionQueryParamsSchema` | Subscription filtering (status, type) |
|
||||
| `orderQueryParamsSchema` | Order filtering (status, orderType) |
|
||||
@ -220,7 +223,7 @@ export class OrdersController {
|
||||
### **Validation Primitives**
|
||||
|
||||
| Schema | Description |
|
||||
|--------|-------------|
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| `emailSchema` | Email validation (lowercase, trimmed) |
|
||||
| `passwordSchema` | Strong password (8+ chars, mixed case, number, special) |
|
||||
| `nameSchema` | Name validation (1-100 chars) |
|
||||
@ -285,7 +288,7 @@ import {
|
||||
myEntitySchema,
|
||||
myEntityQueryParamsSchema,
|
||||
type MyEntity,
|
||||
type MyEntityQueryParams
|
||||
type MyEntityQueryParams,
|
||||
} from "@customer-portal/domain/my-domain";
|
||||
|
||||
@Controller("my-entities")
|
||||
@ -370,13 +373,15 @@ export const paginationSchema = z.object({
|
||||
### 4. **Use Refinements for Complex Validation**
|
||||
|
||||
```typescript
|
||||
export const simActivationSchema = z.object({
|
||||
export const simActivationSchema = z
|
||||
.object({
|
||||
simType: z.enum(["eSIM", "Physical SIM"]),
|
||||
eid: z.string().optional(),
|
||||
}).refine(
|
||||
(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
|
||||
**Last Updated**: October 2025
|
||||
|
||||
|
||||
@ -11,4 +11,3 @@ type SignupRequestInput = z.input<typeof signupRequestSchema>;
|
||||
export function buildSignupRequest(input: SignupRequestInput) {
|
||||
return signupRequestSchema.parse(input);
|
||||
}
|
||||
|
||||
|
||||
@ -35,5 +35,4 @@ export type {
|
||||
BillingSummary,
|
||||
InvoiceQueryParams,
|
||||
InvoiceListQuery,
|
||||
} from './schema.js';
|
||||
|
||||
} from "./schema.js";
|
||||
|
||||
@ -25,7 +25,7 @@ export type {
|
||||
BillingSummary,
|
||||
InvoiceQueryParams,
|
||||
InvoiceListQuery,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
// Provider adapters
|
||||
export * as Providers from "./providers/index.js";
|
||||
|
||||
@ -83,9 +83,7 @@ export function transformWhmcsInvoice(
|
||||
|
||||
const currency = whmcsInvoice.currencycode || options.defaultCurrencyCode || "JPY";
|
||||
const currencySymbol =
|
||||
whmcsInvoice.currencyprefix ||
|
||||
whmcsInvoice.currencysuffix ||
|
||||
options.defaultCurrencySymbol;
|
||||
whmcsInvoice.currencyprefix || whmcsInvoice.currencysuffix || options.defaultCurrencySymbol;
|
||||
|
||||
// Transform to domain model
|
||||
const invoice: Invoice = {
|
||||
|
||||
@ -274,13 +274,21 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
|
||||
* 2. Flat format: currencies[currency][0][id], currencies[currency][0][code], etc.
|
||||
* 3. Missing result field in some cases
|
||||
*/
|
||||
export const whmcsCurrenciesResponseSchema = z.object({
|
||||
export const whmcsCurrenciesResponseSchema = z
|
||||
.object({
|
||||
result: z.enum(["success", "error"]).optional(),
|
||||
totalresults: z.string().transform(val => parseInt(val, 10)).or(z.number()).optional(),
|
||||
currencies: z.object({
|
||||
totalresults: z
|
||||
.string()
|
||||
.transform(val => parseInt(val, 10))
|
||||
.or(z.number())
|
||||
.optional(),
|
||||
currencies: z
|
||||
.object({
|
||||
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Allow any additional flat currency keys for flat format
|
||||
}).catchall(z.string().or(z.number()));
|
||||
})
|
||||
.catchall(z.string().or(z.number()));
|
||||
|
||||
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;
|
||||
|
||||
@ -15,4 +15,3 @@ export * as CommonProviders from "./providers/index.js";
|
||||
// Re-export provider types for convenience
|
||||
export type { WhmcsResponse, WhmcsErrorResponse } from "./providers/whmcs.js";
|
||||
export type { SalesforceResponse } from "./providers/salesforce.js";
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ type SalesforceResponseBase = z.infer<typeof salesforceResponseBaseSchema>;
|
||||
*
|
||||
* Usage: SalesforceResponse<SalesforceOrderRecord>
|
||||
*/
|
||||
export type SalesforceResponse<TRecord> = Omit<SalesforceResponseBase, 'records'> & {
|
||||
export type SalesforceResponse<TRecord> = Omit<SalesforceResponseBase, "records"> & {
|
||||
records: TRecord[];
|
||||
};
|
||||
|
||||
@ -39,4 +39,3 @@ export const salesforceResponseSchema = <TRecord extends z.ZodTypeAny>(recordSch
|
||||
salesforceResponseBaseSchema.extend({
|
||||
records: z.array(recordSchema),
|
||||
});
|
||||
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "./raw.types.js";
|
||||
|
||||
|
||||
@ -53,4 +53,3 @@ export const whmcsErrorResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export type WhmcsErrorResponse = z.infer<typeof whmcsErrorResponseSchema>;
|
||||
|
||||
|
||||
@ -20,7 +20,11 @@ export const passwordSchema = z
|
||||
.regex(/[0-9]/, "Password must contain at least one number")
|
||||
.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
|
||||
.string()
|
||||
@ -78,7 +82,10 @@ export const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").t
|
||||
/**
|
||||
* 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
|
||||
@ -111,10 +118,7 @@ export const apiErrorResponseSchema = z.object({
|
||||
* Usage: apiResponseSchema(yourDataSchema)
|
||||
*/
|
||||
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
||||
z.discriminatedUnion("success", [
|
||||
apiSuccessResponseSchema(dataSchema),
|
||||
apiErrorResponseSchema,
|
||||
]);
|
||||
z.discriminatedUnion("success", [apiSuccessResponseSchema(dataSchema), apiErrorResponseSchema]);
|
||||
|
||||
// ============================================================================
|
||||
// Pagination Schemas
|
||||
|
||||
@ -33,7 +33,10 @@ export const customerNumberSchema = z.string().min(1, "Customer number is requir
|
||||
* @throws Error if email format is invalid
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -6,4 +6,3 @@
|
||||
|
||||
export * from "./mapper.js";
|
||||
export * from "./types.js";
|
||||
|
||||
|
||||
@ -28,4 +28,3 @@ export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth {
|
||||
updatedAt: raw.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -24,4 +24,3 @@ export interface PrismaUserRaw {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@ -3,27 +3,17 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
CreateMappingRequest,
|
||||
UpdateMappingRequest,
|
||||
UserIdMapping,
|
||||
} from "./contract.js";
|
||||
import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from "./contract.js";
|
||||
|
||||
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
|
||||
userId: z.string().uuid(),
|
||||
whmcsClientId: z.number().int().positive(),
|
||||
sfAccountId: z
|
||||
.string()
|
||||
.min(1, "Salesforce account ID must be at least 1 character")
|
||||
.optional(),
|
||||
sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(),
|
||||
});
|
||||
|
||||
export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({
|
||||
whmcsClientId: z.number().int().positive().optional(),
|
||||
sfAccountId: z
|
||||
.string()
|
||||
.min(1, "Salesforce account ID must be at least 1 character")
|
||||
.optional(),
|
||||
sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(),
|
||||
});
|
||||
|
||||
export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({
|
||||
|
||||
@ -76,7 +76,9 @@ export function validateNoConflicts(
|
||||
* Note: This assumes the mapping has already been validated.
|
||||
* 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 warnings: string[] = [];
|
||||
|
||||
@ -85,9 +87,7 @@ export function validateDeletion(mapping: UserIdMapping | null | undefined): Map
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
|
||||
);
|
||||
warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
|
||||
if (mapping.sfAccountId) {
|
||||
warnings.push(
|
||||
"This mapping includes Salesforce integration - deletion will affect case management"
|
||||
@ -130,4 +130,3 @@ export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMapp
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +64,10 @@ const BILLING_CYCLE_ALIASES: Record<string, BillingCycle> = {
|
||||
};
|
||||
|
||||
const normalizeBillingCycleKey = (value: string): string =>
|
||||
value.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "");
|
||||
|
||||
const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly";
|
||||
|
||||
@ -337,7 +340,7 @@ export function buildOrderDisplayItems(
|
||||
primaryCategory: category,
|
||||
categories: [category],
|
||||
charges,
|
||||
included: charges.every((charge) => charge.amount <= 0),
|
||||
included: charges.every(charge => charge.amount <= 0),
|
||||
sourceItems: [item],
|
||||
isBundle: Boolean(item.isBundledAddon),
|
||||
};
|
||||
|
||||
@ -79,7 +79,7 @@ export type {
|
||||
OrderDisplayItemCategory,
|
||||
OrderDisplayItemCharge,
|
||||
OrderDisplayItemChargeKind,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
// Provider adapters
|
||||
export * as Providers from "./providers/index.js";
|
||||
|
||||
@ -21,12 +21,7 @@ export const Salesforce = {
|
||||
fieldMap: SalesforceFieldMap,
|
||||
};
|
||||
|
||||
export {
|
||||
WhmcsMapper,
|
||||
WhmcsRaw,
|
||||
SalesforceMapper,
|
||||
SalesforceRaw,
|
||||
};
|
||||
export { WhmcsMapper, WhmcsRaw, SalesforceMapper, SalesforceRaw };
|
||||
export * from "./whmcs/mapper.js";
|
||||
export * from "./whmcs/raw.types.js";
|
||||
export * from "./salesforce/mapper.js";
|
||||
|
||||
@ -69,9 +69,7 @@ export function prepareOrderFromCart(
|
||||
const orderData: CreateOrderRequest = {
|
||||
orderType,
|
||||
skus: uniqueSkus,
|
||||
...(Object.keys(cart.configuration).length > 0
|
||||
? { configurations: cart.configuration }
|
||||
: {}),
|
||||
...(Object.keys(cart.configuration).length > 0 ? { configurations: cart.configuration } : {}),
|
||||
};
|
||||
|
||||
return orderData;
|
||||
|
||||
@ -53,4 +53,4 @@ export type {
|
||||
PaymentGatewayType,
|
||||
PaymentGateway,
|
||||
PaymentGatewayList,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
@ -20,7 +20,7 @@ export type {
|
||||
PaymentGatewayType,
|
||||
PaymentGateway,
|
||||
PaymentGatewayList,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
// Provider adapters
|
||||
export * as Providers from "./providers/index.js";
|
||||
|
||||
@ -77,4 +77,3 @@ export function transformWhmcsPaymentGateway(raw: unknown): PaymentGateway {
|
||||
|
||||
return paymentGatewaySchema.parse(gateway);
|
||||
}
|
||||
|
||||
|
||||
@ -4,4 +4,3 @@
|
||||
*/
|
||||
|
||||
export * as Whmcs from "./whmcs/index.js";
|
||||
|
||||
|
||||
@ -4,4 +4,3 @@
|
||||
*/
|
||||
|
||||
export * from "./utils.js";
|
||||
|
||||
|
||||
@ -64,4 +64,4 @@ export type {
|
||||
SimOrderActivationRequest,
|
||||
SimOrderActivationMnp,
|
||||
SimOrderActivationAddons,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
@ -59,7 +59,7 @@ export type {
|
||||
SimTopUpPricing,
|
||||
SimTopUpPricingPreviewRequest,
|
||||
SimTopUpPricingPreviewResponse,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
export type { SimPlanCode } from "./contract.js";
|
||||
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js";
|
||||
|
||||
|
||||
@ -138,4 +138,3 @@ export const SIM_MANAGEMENT_FLOW: ManagementFlow = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -47,8 +47,12 @@ export type PlanChangeResponse = ReturnType<typeof Mapper.transformFreebitPlanCh
|
||||
export type CancelPlanResponse = ReturnType<typeof Mapper.transformFreebitCancelPlanResponse>;
|
||||
export type CancelAccountResponse = ReturnType<typeof Mapper.transformFreebitCancelAccountResponse>;
|
||||
export type EsimReissueResponse = ReturnType<typeof Mapper.transformFreebitEsimReissueResponse>;
|
||||
export type EsimAddAccountResponse = ReturnType<typeof Mapper.transformFreebitEsimAddAccountResponse>;
|
||||
export type EsimActivationResponse = ReturnType<typeof Mapper.transformFreebitEsimActivationResponse>;
|
||||
export type EsimAddAccountResponse = ReturnType<
|
||||
typeof Mapper.transformFreebitEsimAddAccountResponse
|
||||
>;
|
||||
export type EsimActivationResponse = ReturnType<
|
||||
typeof Mapper.transformFreebitEsimActivationResponse
|
||||
>;
|
||||
export type AuthResponse = ReturnType<typeof Mapper.transformFreebitAuthResponse>;
|
||||
|
||||
export * from "./mapper.js";
|
||||
|
||||
@ -48,11 +48,13 @@ export const freebitTrafficInfoRawSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
account: z.union([z.string(), z.number()]).optional(),
|
||||
traffic: z.object({
|
||||
traffic: z
|
||||
.object({
|
||||
today: z.union([z.string(), z.number()]).optional(),
|
||||
inRecentDays: z.union([z.string(), z.number()]).optional(),
|
||||
blackList: z.union([z.string(), z.number()]).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export const freebitTopUpRawSchema = z.object({
|
||||
resultCode: z.string().optional(),
|
||||
@ -154,14 +156,16 @@ export const freebitQuotaHistoryRawSchema = z.object({
|
||||
account: z.union([z.string(), z.number()]).optional(),
|
||||
total: z.union([z.string(), z.number()]).optional(),
|
||||
count: z.union([z.string(), z.number()]).optional(),
|
||||
quotaHistory: z.array(
|
||||
quotaHistory: z
|
||||
.array(
|
||||
z.object({
|
||||
addQuotaKb: z.union([z.string(), z.number()]).optional(),
|
||||
addDate: z.string().optional(),
|
||||
expireDate: z.string().optional(),
|
||||
campaignCode: z.string().optional(),
|
||||
})
|
||||
).optional(),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type FreebitQuotaHistoryRaw = z.infer<typeof freebitQuotaHistoryRawSchema>;
|
||||
@ -178,4 +182,3 @@ export const freebitAuthResponseRawSchema = z.object({
|
||||
});
|
||||
|
||||
export type FreebitAuthResponseRaw = z.infer<typeof freebitAuthResponseRawSchema>;
|
||||
|
||||
|
||||
@ -135,7 +135,10 @@ export const freebitEsimAddAccountRequestSchema = z.object({
|
||||
account: z.string().min(1, "Account is required"),
|
||||
eid: z.string().min(1, "EID is required"),
|
||||
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(),
|
||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||
mnp: freebitEsimMnpSchema.optional(),
|
||||
@ -178,7 +181,10 @@ export const freebitEsimIdentitySchema = z.object({
|
||||
firstnameZenKana: z.string().optional(),
|
||||
lastnameZenKana: z.string().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"),
|
||||
planCode: z.string().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(),
|
||||
// Identity fields (flattened for API)
|
||||
firstnameKanji: z.string().optional(),
|
||||
@ -202,7 +211,10 @@ export const freebitEsimActivationRequestSchema = z.object({
|
||||
firstnameZenKana: z.string().optional(),
|
||||
lastnameZenKana: z.string().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
|
||||
masterAccount: z.string().optional(),
|
||||
masterPassword: z.string().optional(),
|
||||
@ -224,10 +236,12 @@ export const freebitEsimActivationResponseSchema = z.object({
|
||||
resultCode: z.string(),
|
||||
resultMessage: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
status: z.object({
|
||||
status: z
|
||||
.object({
|
||||
statusCode: z.union([z.string(), z.number()]),
|
||||
message: z.string(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
@ -241,7 +255,10 @@ export const freebitEsimActivationParamsSchema = z.object({
|
||||
planCode: z.string().optional(),
|
||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||
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(),
|
||||
identity: freebitEsimIdentitySchema.optional(),
|
||||
});
|
||||
@ -271,4 +288,3 @@ export type FreebitQuotaHistoryResponse = z.infer<typeof freebitQuotaHistoryResp
|
||||
export type FreebitEsimAddAccountRequest = z.infer<typeof freebitEsimAddAccountRequestSchema>;
|
||||
export type FreebitAuthRequest = z.infer<typeof freebitAuthRequestSchema>;
|
||||
export type FreebitCancelAccountRequest = z.infer<typeof freebitCancelAccountRequestSchema>;
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
* Removes all non-digit characters from account 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 {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
@ -43,4 +43,3 @@ export function parseDateFromApi(dateString: string): Date | null {
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
|
||||
@ -138,12 +138,8 @@ export const simCancelRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export const simTopUpHistoryRequestSchema = z.object({
|
||||
fromDate: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
|
||||
toDate: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
|
||||
fromDate: z.string().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({
|
||||
@ -162,16 +158,19 @@ export const simReissueRequestSchema = z.object({
|
||||
});
|
||||
|
||||
// Enhanced cancellation request with more details
|
||||
export const simCancelFullRequestSchema = z.object({
|
||||
cancellationMonth: z.string().regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
export const simCancelFullRequestSchema = z
|
||||
.object({
|
||||
cancellationMonth: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
confirmRead: z.boolean(),
|
||||
confirmCancel: z.boolean(),
|
||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||
comments: z.string().max(1000).optional(),
|
||||
}).refine(
|
||||
(data) => data.confirmRead === true && data.confirmCancel === true,
|
||||
{ message: "You must confirm both checkboxes to proceed" }
|
||||
);
|
||||
})
|
||||
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
|
||||
message: "You must confirm both checkboxes to proceed",
|
||||
});
|
||||
|
||||
// Top-up request with enhanced details for email
|
||||
export const simTopUpFullRequestSchema = z.object({
|
||||
@ -292,7 +291,10 @@ export const simOrderActivationMnpSchema = z.object({
|
||||
firstnameZenKana: z.string().optional(),
|
||||
lastnameZenKana: 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({
|
||||
@ -300,19 +302,24 @@ export const simOrderActivationAddonsSchema = z.object({
|
||||
callWaiting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const simOrderActivationRequestSchema = z.object({
|
||||
export const simOrderActivationRequestSchema = z
|
||||
.object({
|
||||
planSku: z.string().min(1, "Plan SKU is required"),
|
||||
simType: simCardTypeSchema,
|
||||
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
|
||||
activationType: simActivationTypeSchema,
|
||||
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(),
|
||||
scheduledAt: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
||||
.optional(),
|
||||
addons: simOrderActivationAddonsSchema.optional(),
|
||||
mnp: simOrderActivationMnpSchema.optional(),
|
||||
msisdn: z.string().min(1, "Phone number (msisdn) is required"),
|
||||
oneTimeAmountJpy: z.number().nonnegative("One-time amount must be non-negative"),
|
||||
monthlyAmountJpy: z.number().nonnegative("Monthly amount must be non-negative"),
|
||||
}).refine(
|
||||
(data) => {
|
||||
})
|
||||
.refine(
|
||||
data => {
|
||||
// If simType is eSIM, eid is required
|
||||
if (data.simType === "eSIM" && (!data.eid || data.eid.length < 15)) {
|
||||
return false;
|
||||
@ -323,8 +330,9 @@ export const simOrderActivationRequestSchema = z.object({
|
||||
message: "EID is required for eSIM and must be at least 15 characters",
|
||||
path: ["eid"],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
)
|
||||
.refine(
|
||||
data => {
|
||||
// If activationType is Scheduled, scheduledAt is required
|
||||
if (data.activationType === "Scheduled" && !data.scheduledAt) {
|
||||
return false;
|
||||
@ -335,7 +343,7 @@ export const simOrderActivationRequestSchema = z.object({
|
||||
message: "Scheduled date is required for Scheduled activation",
|
||||
path: ["scheduledAt"],
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequestSchema>;
|
||||
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
|
||||
|
||||
@ -45,4 +45,4 @@ export type {
|
||||
SubscriptionList,
|
||||
SubscriptionQueryParams,
|
||||
SubscriptionQuery,
|
||||
} from './schema.js';
|
||||
} from "./schema.js";
|
||||
|
||||
@ -8,20 +8,16 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const normalizeRequiredNumber = z.preprocess(
|
||||
value => {
|
||||
const normalizeRequiredNumber = z.preprocess(value => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
z.number()
|
||||
);
|
||||
}, z.number());
|
||||
|
||||
const normalizeOptionalNumber = z.preprocess(
|
||||
value => {
|
||||
const normalizeOptionalNumber = z.preprocess(value => {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
@ -29,9 +25,7 @@ const normalizeOptionalNumber = z.preprocess(
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z.number().optional()
|
||||
);
|
||||
}, z.number().optional());
|
||||
|
||||
const optionalStringField = () =>
|
||||
z
|
||||
@ -148,12 +142,14 @@ export const whmcsProductRawSchema = z.object({
|
||||
configoptions: whmcsConfigOptionsContainerSchema.optional(),
|
||||
|
||||
// Pricing details
|
||||
pricing: z.object({
|
||||
pricing: z
|
||||
.object({
|
||||
amount: z.union([z.string(), z.number()]).optional(),
|
||||
currency: z.string().optional(),
|
||||
currencyprefix: z.string().optional(),
|
||||
currencysuffix: z.string().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type WhmcsProductRaw = z.infer<typeof whmcsProductRawSchema>;
|
||||
@ -167,13 +163,15 @@ export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
|
||||
* WHMCS GetClientsProducts API response schema
|
||||
*/
|
||||
const whmcsProductContainerSchema = z.object({
|
||||
product: z
|
||||
.preprocess(value => {
|
||||
product: z.preprocess(
|
||||
value => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}, z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional()),
|
||||
},
|
||||
z.union([whmcsProductRawSchema, z.array(whmcsProductRawSchema)]).optional()
|
||||
),
|
||||
});
|
||||
|
||||
export const whmcsProductListResponseSchema = z.object({
|
||||
|
||||
@ -15,4 +15,3 @@ export const Salesforce = {
|
||||
export { SalesforceMapper, SalesforceRaw };
|
||||
export * from "./salesforce/mapper.js";
|
||||
export * from "./salesforce/raw.types.js";
|
||||
|
||||
|
||||
@ -4,4 +4,3 @@
|
||||
|
||||
export * from "./raw.types.js";
|
||||
export * from "./mapper.js";
|
||||
|
||||
|
||||
@ -46,9 +46,7 @@ function nowIsoString(): string {
|
||||
* @param record - Raw Salesforce Case record from SOQL query
|
||||
* @returns Validated SupportCase domain object
|
||||
*/
|
||||
export function transformSalesforceCaseToSupportCase(
|
||||
record: SalesforceCaseRecord
|
||||
): SupportCase {
|
||||
export function transformSalesforceCaseToSupportCase(record: SalesforceCaseRecord): SupportCase {
|
||||
// Get raw values
|
||||
const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW;
|
||||
const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||
@ -167,4 +165,3 @@ export function buildCaseByIdQuery(
|
||||
LIMIT 1
|
||||
`.trim();
|
||||
}
|
||||
|
||||
|
||||
@ -319,4 +319,3 @@ const PRIORITY_TO_SALESFORCE: Record<string, string> = {
|
||||
export function toSalesforcePriority(displayPriority: string): string {
|
||||
return PRIORITY_TO_SALESFORCE[displayPriority] ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SUPPORT_CASE_STATUS,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
} from "./contract.js";
|
||||
import { SUPPORT_CASE_STATUS, SUPPORT_CASE_PRIORITY, SUPPORT_CASE_CATEGORY } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Portal status values (mapped from Salesforce Japanese API names)
|
||||
|
||||
@ -5,6 +5,7 @@ Utility functions and helpers for the domain layer. This package contains **util
|
||||
## Purpose
|
||||
|
||||
The toolkit provides pure utility functions for common operations like:
|
||||
|
||||
- String manipulation
|
||||
- Date/time formatting
|
||||
- Currency formatting
|
||||
@ -43,10 +44,10 @@ Validation determines if input is valid or invalid:
|
||||
|
||||
```typescript
|
||||
// These are VALIDATION functions → Use common/validation.ts
|
||||
isValidEmail(email) // Returns true/false
|
||||
isValidUuid(id) // Returns true/false
|
||||
isValidUrl(url) // Returns true/false
|
||||
validateUrlOrThrow(url) // Throws if invalid
|
||||
isValidEmail(email); // Returns true/false
|
||||
isValidUuid(id); // Returns true/false
|
||||
isValidUrl(url); // Returns true/false
|
||||
validateUrlOrThrow(url); // Throws if invalid
|
||||
```
|
||||
|
||||
**Location**: `packages/domain/common/validation.ts`
|
||||
@ -57,10 +58,10 @@ Utilities transform, extract, or manipulate data:
|
||||
|
||||
```typescript
|
||||
// These are UTILITY functions → Use toolkit
|
||||
getEmailDomain(email) // Extracts domain from email
|
||||
normalizeEmail(email) // Lowercases and trims
|
||||
ensureProtocol(url) // Adds https:// if missing
|
||||
getHostname(url) // Extracts hostname
|
||||
getEmailDomain(email); // Extracts domain from email
|
||||
normalizeEmail(email); // Lowercases and trims
|
||||
ensureProtocol(url); // Adds https:// if missing
|
||||
getHostname(url); // Extracts hostname
|
||||
```
|
||||
|
||||
**Location**: `packages/domain/toolkit/`
|
||||
@ -108,7 +109,7 @@ const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
|
||||
## When to Use What
|
||||
|
||||
| Task | Use | Example |
|
||||
|------|-----|---------|
|
||||
| --------------------- | -------------------------------- | ------------------------------- |
|
||||
| Validate email format | `common/validation.ts` | `isValidEmail(email)` |
|
||||
| Extract email domain | `toolkit/validation/email.ts` | `getEmailDomain(email)` |
|
||||
| Validate URL format | `common/validation.ts` | `isValidUrl(url)` |
|
||||
@ -119,6 +120,7 @@ const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
|
||||
## Best Practices
|
||||
|
||||
1. **Use validation functions for validation**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
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**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import { normalizeEmail } from "@customer-portal/domain/toolkit/validation/email";
|
||||
@ -139,6 +142,7 @@ const normalized = normalizeEmail(" User@Example.COM "); // "user@example.com"
|
||||
```
|
||||
|
||||
3. **Don't mix validation and utilities**
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - mixing concerns
|
||||
function processEmail(email: string) {
|
||||
@ -181,4 +185,3 @@ If you're unsure whether something belongs in toolkit or common/validation:
|
||||
- **Ask**: "Does this transform or extract data?"
|
||||
- YES → It's a utility → Use `toolkit/`
|
||||
- NO → Might be validation → Use `common/validation.ts`
|
||||
|
||||
|
||||
@ -17,10 +17,7 @@ export interface DateFormatOptions {
|
||||
/**
|
||||
* Format an ISO date string for display
|
||||
*/
|
||||
export function formatDate(
|
||||
isoString: string,
|
||||
options: DateFormatOptions = {}
|
||||
): string {
|
||||
export function formatDate(isoString: string, options: DateFormatOptions = {}): string {
|
||||
const {
|
||||
locale = "en-US",
|
||||
dateStyle = "medium",
|
||||
@ -51,10 +48,7 @@ export function formatDate(
|
||||
/**
|
||||
* Format a date relative to now (e.g., "2 days ago", "in 3 hours")
|
||||
*/
|
||||
export function formatRelativeDate(
|
||||
isoString: string,
|
||||
options: { locale?: string } = {}
|
||||
): string {
|
||||
export function formatRelativeDate(isoString: string, options: { locale?: string } = {}): string {
|
||||
const { locale = "en-US" } = options;
|
||||
|
||||
try {
|
||||
@ -90,4 +84,3 @@ export function isValidDate(dateString: string): boolean {
|
||||
const date = new Date(dateString);
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
|
||||
@ -8,4 +8,3 @@ export * from "./currency.js";
|
||||
export * from "./date.js";
|
||||
export * from "./phone.js";
|
||||
export * from "./text.js";
|
||||
|
||||
|
||||
@ -88,4 +88,3 @@ export function formatPhoneForWhmcs(phone: string): string {
|
||||
// Return with + prefix if it had one, otherwise as-is
|
||||
return hasPlus ? `+${digits}` : digits;
|
||||
}
|
||||
|
||||
|
||||
@ -64,4 +64,3 @@ export function maskString(str: string, visibleStart = 3, visibleEnd = 3, maskCh
|
||||
|
||||
return `${start}${masked}${end}`;
|
||||
}
|
||||
|
||||
|
||||
@ -24,4 +24,3 @@ export {
|
||||
isSuccess,
|
||||
isError,
|
||||
} from "./typing/helpers.js";
|
||||
|
||||
|
||||
@ -62,4 +62,3 @@ export function assertNumber(
|
||||
export function assertNever(value: never, message = "Unexpected value"): never {
|
||||
throw new AssertionError(`${message}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
|
||||
@ -59,4 +59,3 @@ export function isDefined<T>(value: T | null | undefined): value is T {
|
||||
export function filterDefined<T>(arr: (T | null | undefined)[]): T[] {
|
||||
return arr.filter(isDefined);
|
||||
}
|
||||
|
||||
|
||||
@ -7,4 +7,3 @@
|
||||
export * from "./guards.js";
|
||||
export * from "./assertions.js";
|
||||
export * from "./helpers.js";
|
||||
|
||||
|
||||
@ -19,4 +19,3 @@ export function getEmailDomain(email: string): string | null {
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
@ -8,5 +8,3 @@ export * from "./email.js";
|
||||
export * from "./url.js";
|
||||
export * from "./string.js";
|
||||
export * from "./helpers.js";
|
||||
|
||||
|
||||
|
||||
@ -45,4 +45,3 @@ export function isAlpha(str: string): boolean {
|
||||
export function isNumeric(str: string): boolean {
|
||||
return /^\d+$/.test(str);
|
||||
}
|
||||
|
||||
|
||||
@ -26,4 +26,3 @@ export function getHostname(url: string): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -241,6 +241,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.17
|
||||
version: 4.1.17
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@4.1.17)
|
||||
typescript:
|
||||
specifier: "catalog:"
|
||||
version: 5.9.3
|
||||
@ -6874,6 +6877,14 @@ packages:
|
||||
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:
|
||||
resolution:
|
||||
{
|
||||
@ -11504,6 +11515,10 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.17):
|
||||
dependencies:
|
||||
tailwindcss: 4.1.17
|
||||
|
||||
tailwindcss@4.1.17: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user