Merge pull request #42 from NTumurbars/Homepage

Homepage small fixes
This commit is contained in:
NTumurbars 2025-12-25 17:31:20 +09:00 committed by GitHub
commit b8da286fce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 488 additions and 452 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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)
*/
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
*/
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 {
}
}
}

View File

@ -26,4 +26,3 @@ import { RateLimitGuard } from "./rate-limit.guard.js";
exports: [RateLimitGuard],
})
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 { Logger } from "nestjs-pino";
import type { Request } from "express";

View File

@ -47,8 +47,6 @@ export class BaseServicesService {
} catch (error: unknown) {
this.logger.error(`Query failed: ${context}`, {
error: getErrorMessage(error),
soql,
context,
});
return [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ export type {
BillingSummary,
InvoiceQueryParams,
InvoiceListQuery,
} from './schema.js';
} from "./schema.js";
// Provider adapters
export * as Providers from "./providers/index.js";

View File

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

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.
* 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>;

View File

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

View File

@ -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),
});

View File

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

View File

@ -53,4 +53,3 @@ export const whmcsErrorResponseSchema = z.object({
});
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(/[^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

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
};

View File

@ -79,7 +79,7 @@ export type {
OrderDisplayItemCategory,
OrderDisplayItemCharge,
OrderDisplayItemChargeKind,
} from './schema.js';
} from "./schema.js";
// Provider adapters
export * as Providers from "./providers/index.js";

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ export type {
PaymentGatewayType,
PaymentGateway,
PaymentGatewayList,
} from './schema.js';
} from "./schema.js";
// Provider adapters
export * as Providers from "./providers/index.js";

View File

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

View File

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

View File

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

View File

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

View File

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

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 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";

View File

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

View File

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

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}

View File

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

View File

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

View File

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

View File

@ -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());
}

View File

@ -8,4 +8,3 @@ export * from "./currency.js";
export * from "./date.js";
export * from "./phone.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 hasPlus ? `+${digits}` : digits;
}

View File

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

View File

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

View File

@ -62,4 +62,3 @@ export function assertNumber(
export function assertNever(value: never, message = "Unexpected value"): never {
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[] {
return arr.filter(isDefined);
}

View File

@ -7,4 +7,3 @@
export * from "./guards.js";
export * from "./assertions.js";
export * from "./helpers.js";

View File

@ -19,4 +19,3 @@ export function getEmailDomain(email: string): string | null {
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}

View File

@ -8,5 +8,3 @@ export * from "./email.js";
export * from "./url.js";
export * from "./string.js";
export * from "./helpers.js";

View File

@ -45,4 +45,3 @@ export function isAlpha(str: string): boolean {
export function isNumeric(str: string): boolean {
return /^\d+$/.test(str);
}

View File

@ -26,4 +26,3 @@ export function getHostname(url: string): string | null {
return null;
}
}

15
pnpm-lock.yaml generated
View File

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