Refactor TypeScript configurations and enhance logging setup
- Updated nest-cli.json to enable output directory deletion and refined TypeScript compiler options. - Modified package.json to improve development command for BFF with preserved watch output. - Adjusted tsconfig.json to extend from a higher-level configuration and removed unnecessary options. - Enhanced logging.module.ts to simplify logger configuration and improve log message formatting. - Updated next.config.mjs to manage server-only libraries and optimize Webpack configuration. - Refined error logging in various components for better clarity and consistency.
This commit is contained in:
parent
807d37a729
commit
b0c8103ee2
71
LOGGER_FIXES_SUMMARY.md
Normal file
71
LOGGER_FIXES_SUMMARY.md
Normal file
@ -0,0 +1,71 @@
|
||||
# ✅ Logger Issues Fixed
|
||||
|
||||
## **Problems Solved:**
|
||||
|
||||
### 1. **fs/promises Module Resolution Error**
|
||||
- **Issue**: Complex logging system was trying to import Node.js `fs/promises` in browser
|
||||
- **Fix**: Removed complex logging directory, created single simple Pino logger
|
||||
- **Result**: No more browser bundling errors
|
||||
|
||||
### 2. **Logo 404 Error**
|
||||
- **Issue**: Missing logo file at `/assets/images/logo.png`
|
||||
- **Fix**: Added `logo.svg` at `apps/portal/public/assets/images/logo.svg`
|
||||
- **Result**: Logo loads correctly
|
||||
|
||||
### 3. **Overcomplicated Logger System**
|
||||
- **Issue**: Multiple logger types, complex configurations, server-only code in browser
|
||||
- **Fix**: Single Pino logger in `packages/shared/src/logger.ts`
|
||||
- **Result**: Simple, centralized logging everywhere
|
||||
|
||||
### 4. **TypeScript Compilation Issues**
|
||||
- **Issue**: Shared package wasn't building due to module resolution conflicts
|
||||
- **Fix**: Updated `tsconfig.json` to use CommonJS and Node module resolution
|
||||
- **Result**: Clean builds and type checking
|
||||
|
||||
### 5. **Incorrect Pino API Usage**
|
||||
- **Issue**: Frontend code using `logger.error("message", error)` but Pino expects `logger.error(error, "message")`
|
||||
- **Fix**: Updated all logger calls to use correct Pino API
|
||||
- **Result**: No TypeScript errors, proper logging
|
||||
|
||||
## **Final Architecture:**
|
||||
|
||||
```
|
||||
packages/shared/src/logger.ts ← Single Pino logger
|
||||
├── Frontend: import { logger, log } from "@customer-portal/shared"
|
||||
├── Backend: @Inject(Logger) + nestjs-pino (uses same config)
|
||||
└── Helper: log.error("message", error) for convenience
|
||||
```
|
||||
|
||||
## **Usage Examples:**
|
||||
|
||||
**Frontend:**
|
||||
```typescript
|
||||
import { logger, log } from "@/lib/logger";
|
||||
|
||||
// Pino API (data first, message second)
|
||||
logger.error(error, "Something failed");
|
||||
logger.info({ userId: "123" }, "User action");
|
||||
|
||||
// Helper functions (message first, data second)
|
||||
log.error("Something failed", error);
|
||||
log.info("User action", { userId: "123" });
|
||||
```
|
||||
|
||||
**Backend:**
|
||||
```typescript
|
||||
// Dependency injection (recommended)
|
||||
constructor(@Inject(Logger) private logger: Logger) {}
|
||||
this.logger.info({ userId: "123" }, "User action");
|
||||
|
||||
// Direct import (if needed)
|
||||
import { logger } from "@customer-portal/shared";
|
||||
logger.info({ userId: "123" }, "User action");
|
||||
```
|
||||
|
||||
## **Status: ✅ All Fixed**
|
||||
- ✅ No more fs/promises errors
|
||||
- ✅ Logo displays correctly
|
||||
- ✅ Single centralized logger
|
||||
- ✅ Clean TypeScript compilation
|
||||
- ✅ Proper Pino API usage
|
||||
- ✅ Production-ready with security redaction
|
||||
66
LOGGING_USAGE.md
Normal file
66
LOGGING_USAGE.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Simple Centralized Logging
|
||||
|
||||
## ✅ **Single Pino Logger Everywhere**
|
||||
|
||||
We now use **one simple Pino logger** across the entire application:
|
||||
|
||||
- **Frontend (Portal)**: Uses the same Pino logger
|
||||
- **Backend (BFF)**: Uses `nestjs-pino` with the same configuration
|
||||
- **Shared**: Single logger configuration
|
||||
|
||||
## 🚀 **Usage Examples**
|
||||
|
||||
### **Frontend (Portal)**
|
||||
```typescript
|
||||
import { logger, log } from "@/lib/logger";
|
||||
|
||||
// Simple logging
|
||||
log.info("User logged in", { userId: "123" });
|
||||
log.error("API call failed", error);
|
||||
|
||||
// Direct Pino usage
|
||||
logger.info({ userId: "123" }, "User logged in");
|
||||
```
|
||||
|
||||
### **Backend (BFF) - Dependency Injection**
|
||||
```typescript
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||
|
||||
async findUser(id: string) {
|
||||
this.logger.info({ userId: id }, "Finding user");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Backend (BFF) - Direct Import**
|
||||
```typescript
|
||||
import { logger, log } from "@customer-portal/shared";
|
||||
|
||||
// Simple logging
|
||||
log.info("Service started");
|
||||
log.error("Database error", error);
|
||||
|
||||
// Direct Pino usage
|
||||
logger.info({ userId: "123" }, "User action");
|
||||
```
|
||||
|
||||
## 🔧 **Configuration**
|
||||
|
||||
All configuration is in one place: `packages/shared/src/logger.ts`
|
||||
|
||||
- **Development**: Pretty printed logs with colors
|
||||
- **Production**: JSON logs for log aggregation
|
||||
- **Browser**: Console-friendly output
|
||||
- **Security**: Automatic redaction of sensitive fields
|
||||
|
||||
## 🎯 **Benefits**
|
||||
|
||||
- ✅ **One logger** instead of multiple complex systems
|
||||
- ✅ **Same configuration** everywhere
|
||||
- ✅ **No more fs/promises errors**
|
||||
- ✅ **Simple imports** - just `import { log } from "@customer-portal/shared"`
|
||||
- ✅ **Production ready** with automatic security redaction
|
||||
@ -2,12 +2,10 @@
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"entryFile": "main",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"deleteOutDir": true,
|
||||
"watchAssets": true,
|
||||
"assets": ["**/*.prisma"],
|
||||
"tsConfigPath": "./tsconfig.build.json",
|
||||
"builder": "tsc"
|
||||
"assets": ["**/*.prisma"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"build": "nest build -c tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch",
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation\" nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint .",
|
||||
|
||||
@ -1,16 +1,40 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { LoggerModule } from "nestjs-pino";
|
||||
import { createNestPinoConfig } from "@customer-portal/shared";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) =>
|
||||
await createNestPinoConfig(configService),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
name: process.env.APP_NAME || "customer-portal-bff",
|
||||
transport: process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "yyyy-mm-dd HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
redact: {
|
||||
paths: [
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"jwt",
|
||||
"apiKey",
|
||||
],
|
||||
remove: true,
|
||||
},
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
bindings: () => ({}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [LoggerModule],
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"noEmit": true,
|
||||
"baseUrl": "./",
|
||||
"removeComments": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@ -1,9 +1,20 @@
|
||||
/* eslint-env node */
|
||||
import path from "node:path";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Enable standalone output only for production deployment
|
||||
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
|
||||
|
||||
// Tell Next to NOT bundle these server-only libs
|
||||
serverExternalPackages: [
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"pino-abstract-transport",
|
||||
"thread-stream",
|
||||
"sonic-boom",
|
||||
],
|
||||
|
||||
// Turbopack configuration (Next.js 15.5+)
|
||||
turbopack: {
|
||||
// Enable Turbopack optimizations
|
||||
@ -77,8 +88,20 @@ const nextConfig = {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
// Note: Webpack configuration removed - using Turbopack exclusively
|
||||
// Turbopack handles bundling automatically with better performance
|
||||
// Webpack configuration for fallback compatibility
|
||||
webpack: (config, { isServer }) => {
|
||||
config.resolve.alias["@"] = path.resolve(process.cwd(), "src");
|
||||
if (isServer) {
|
||||
config.externals.push(
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"pino-abstract-transport",
|
||||
"thread-stream",
|
||||
"sonic-boom",
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p ${NEXT_PORT:-3000}",
|
||||
"build": "next build --turbopack",
|
||||
"build": "next build",
|
||||
"build:turbo": "next build --turbopack",
|
||||
"start": "next start -p ${NEXT_PORT:-3000}",
|
||||
"lint": "eslint .",
|
||||
|
||||
@ -169,7 +169,7 @@ export default function ProfilePage() {
|
||||
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
logger.error("Error updating profile:", error);
|
||||
logger.error(error, "Error updating profile");
|
||||
// You might want to show a toast notification here
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@ -215,7 +215,7 @@ export default function ProfilePage() {
|
||||
|
||||
setIsEditingAddress(false);
|
||||
} catch (error) {
|
||||
logger.error("Error updating address:", error);
|
||||
logger.error(error, "Error updating address");
|
||||
setError(error instanceof Error ? error.message : "Failed to update address");
|
||||
} finally {
|
||||
setIsSavingAddress(false);
|
||||
|
||||
@ -50,7 +50,7 @@ export default function InvoiceDetailPage() {
|
||||
window.open(ssoLink.url, "_blank");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to create SSO link:", error);
|
||||
logger.error(error, "Failed to create SSO link");
|
||||
// You might want to show a toast notification here
|
||||
} finally {
|
||||
// Reset the appropriate loading state
|
||||
@ -92,7 +92,7 @@ export default function InvoiceDetailPage() {
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
window.open(url, "_blank");
|
||||
} catch (error) {
|
||||
logger.error("Failed to create payment methods SSO link:", error);
|
||||
logger.error(error, "Failed to create payment methods SSO link");
|
||||
} finally {
|
||||
setLoadingPaymentMethods(false);
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export default function PaymentMethodsPage() {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
logger.error("Failed to open payment methods:", error);
|
||||
logger.error(error, "Failed to open payment methods");
|
||||
// Simplified, no WHMCS linking prompts
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setError("Authentication failed. Please log in again.");
|
||||
|
||||
@ -49,7 +49,7 @@ export default function DashboardPage() {
|
||||
const ssoLink = await createInvoiceSsoLink(invoiceId, "pay");
|
||||
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
logger.error("Failed to create payment link:", error);
|
||||
logger.error(error, "Failed to create payment link");
|
||||
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
|
||||
} finally {
|
||||
setPaymentLoading(false);
|
||||
|
||||
@ -34,7 +34,7 @@ export default function NewSupportCasePage() {
|
||||
// Redirect to cases list with success message
|
||||
router.push("/support/cases?created=true");
|
||||
} catch (error) {
|
||||
logger.error("Error creating case:", error);
|
||||
logger.error(error, "Error creating case");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ export function SessionTimeoutWarning({
|
||||
return () => clearTimeout(warningTimeout);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error parsing JWT token:", error);
|
||||
logger.error(error, "Error parsing JWT token");
|
||||
void logout();
|
||||
return undefined;
|
||||
}
|
||||
@ -91,7 +91,7 @@ export function SessionTimeoutWarning({
|
||||
setShowWarning(false);
|
||||
setTimeLeft(0);
|
||||
} catch (error) {
|
||||
logger.error("Failed to extend session:", error);
|
||||
logger.error(error, "Failed to extend session");
|
||||
await logout();
|
||||
}
|
||||
})();
|
||||
|
||||
@ -12,7 +12,7 @@ export function Logo({ className = "", size = 32 }: LogoProps) {
|
||||
return (
|
||||
<div className={className} style={{ width: size, height: size }}>
|
||||
<Image
|
||||
src="/assets/images/logo.png"
|
||||
src="/assets/images/logo.svg"
|
||||
alt="Assist Solutions Logo"
|
||||
width={size}
|
||||
height={size}
|
||||
|
||||
@ -172,7 +172,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
try {
|
||||
await authAPI.logout(token);
|
||||
} catch (error) {
|
||||
logger.error("Logout API call failed:", error);
|
||||
logger.error(error, "Logout API call failed");
|
||||
// Continue with local logout even if API call fails
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPinoLogger, getSharedLogger } from "@customer-portal/shared";
|
||||
// Simple re-export of the shared logger
|
||||
import { logger, log } from "@customer-portal/shared";
|
||||
|
||||
// Prefer a shared singleton so logs share correlationId/userId across modules
|
||||
export const logger = getSharedLogger();
|
||||
export { logger, log };
|
||||
export default logger;
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
"name": "@customer-portal/shared",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared types and utilities for customer portal",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"private": true,
|
||||
@ -10,7 +9,7 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
|
||||
@ -12,5 +12,5 @@ export * from "./status.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./array-utils.js";
|
||||
|
||||
// Export logging utilities
|
||||
export * from "./logging/index.js";
|
||||
// Export single logger
|
||||
export { logger, log } from "./logger.js";
|
||||
|
||||
63
packages/shared/src/logger.ts
Normal file
63
packages/shared/src/logger.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import pino from "pino";
|
||||
|
||||
// Single, simple Pino logger configuration
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
// Create one logger instance that works everywhere
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
name: process.env.APP_NAME || "customer-portal",
|
||||
|
||||
// Browser vs Node configuration
|
||||
...(isBrowser
|
||||
? {
|
||||
browser: {
|
||||
asObject: true,
|
||||
serialize: true,
|
||||
},
|
||||
}
|
||||
: {
|
||||
transport: isDev
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "yyyy-mm-dd HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
|
||||
// Security: redact sensitive fields
|
||||
redact: {
|
||||
paths: [
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"jwt",
|
||||
"apiKey",
|
||||
],
|
||||
remove: true,
|
||||
},
|
||||
|
||||
// Clean output format
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
bindings: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
// Export the same logger instance everywhere
|
||||
export default logger;
|
||||
|
||||
// Helper functions for common logging patterns
|
||||
export const log = {
|
||||
info: (message: string, data?: any) => logger.info(data, message),
|
||||
error: (message: string, error?: Error | any) => logger.error(error, message),
|
||||
warn: (message: string, data?: any) => logger.warn(data, message),
|
||||
debug: (message: string, data?: any) => logger.debug(data, message),
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Shared logging utilities
|
||||
* Export all logging-related interfaces and configurations
|
||||
*/
|
||||
|
||||
export * from "./logger.config.js";
|
||||
export * from "./logger.interface.js";
|
||||
export * from "./pino-logger.js";
|
||||
export * from "./nest-logger.config.js";
|
||||
@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Centralized logging configuration
|
||||
* Shared between frontend and backend applications
|
||||
*/
|
||||
|
||||
export interface LogConfig {
|
||||
level: string;
|
||||
service: string;
|
||||
environment: string;
|
||||
enableConsole: boolean;
|
||||
enableFile: boolean;
|
||||
enableRemote: boolean;
|
||||
remoteEndpoint?: string;
|
||||
correlationIdHeader?: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
service: string;
|
||||
environment: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
correlationId?: string;
|
||||
userId?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface LogLevels {
|
||||
error: 0;
|
||||
warn: 1;
|
||||
info: 2;
|
||||
debug: 3;
|
||||
trace: 4;
|
||||
}
|
||||
|
||||
export const LOG_LEVELS: LogLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
trace: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_LOG_CONFIG: LogConfig = {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
service: process.env.APP_NAME || "customer-portal",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
enableConsole: true,
|
||||
enableFile: false,
|
||||
enableRemote: false,
|
||||
correlationIdHeader: "x-correlation-id",
|
||||
};
|
||||
|
||||
export function getLogLevel(level: string): number {
|
||||
return LOG_LEVELS[level as keyof LogLevels] ?? LOG_LEVELS.info;
|
||||
}
|
||||
|
||||
export function isLogLevelEnabled(currentLevel: string, targetLevel: string): boolean {
|
||||
return getLogLevel(currentLevel) >= getLogLevel(targetLevel);
|
||||
}
|
||||
|
||||
export function sanitizeLogData(data: unknown): unknown {
|
||||
if (!data || typeof data !== "object") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sensitiveKeys = [
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"jwt",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-auth-token",
|
||||
"bearer",
|
||||
];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => sanitizeLogData(item));
|
||||
}
|
||||
|
||||
const sanitized = { ...(data as Record<string, unknown>) };
|
||||
|
||||
for (const key in sanitized) {
|
||||
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive.toLowerCase()))) {
|
||||
sanitized[key] = "[REDACTED]";
|
||||
} else if (typeof sanitized[key] === "object" && sanitized[key] !== null) {
|
||||
sanitized[key] = sanitizeLogData(sanitized[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function generateCorrelationId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function formatLogEntry(entry: Omit<LogEntry, "timestamp">): LogEntry {
|
||||
return {
|
||||
...entry,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Shared logger interface
|
||||
* Implemented by both frontend and backend logging systems
|
||||
*/
|
||||
|
||||
// Note: Keep interfaces decoupled from concrete logger config to avoid tight coupling
|
||||
|
||||
export interface ILogger {
|
||||
// Basic logging methods
|
||||
debug(message: string, data?: unknown): void;
|
||||
info(message: string, data?: unknown): void;
|
||||
warn(message: string, data?: unknown): void;
|
||||
error(message: string, data?: unknown): void;
|
||||
trace(message: string, data?: unknown): void;
|
||||
|
||||
// Structured logging methods
|
||||
logApiCall(
|
||||
endpoint: string,
|
||||
method: string,
|
||||
status: number,
|
||||
duration: number,
|
||||
data?: unknown
|
||||
): void;
|
||||
logUserAction(userId: string, action: string, data?: unknown): void;
|
||||
logError(error: Error, context?: string, data?: unknown): void;
|
||||
logRequest(req: Record<string, unknown>, data?: unknown): void;
|
||||
logResponse(res: Record<string, unknown>, data?: unknown): void;
|
||||
|
||||
// Utility methods
|
||||
setCorrelationId(id: string): void;
|
||||
setUserId(id: string): void;
|
||||
setRequestId(id: string): void;
|
||||
|
||||
// Child logger for context
|
||||
child(context: Record<string, unknown>): ILogger;
|
||||
|
||||
// Flush logs (for async operations)
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoggerContext {
|
||||
correlationId?: string;
|
||||
userId?: string;
|
||||
requestId?: string;
|
||||
service?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
level?: string;
|
||||
service?: string;
|
||||
environment?: string;
|
||||
context?: LoggerContext;
|
||||
enableConsole?: boolean;
|
||||
enableFile?: boolean;
|
||||
enableRemote?: boolean;
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
// Lightweight, framework-agnostic factory that returns an object compatible
|
||||
// with nestjs-pino's LoggerModule.forRoot({ pinoHttp: {...} }) shape without importing types.
|
||||
import { join } from "path";
|
||||
|
||||
// Dynamic import for fs/promises - will be resolved at runtime
|
||||
async function getMkdir() {
|
||||
if (typeof window !== 'undefined' || typeof process === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = await import("fs/promises");
|
||||
return fs.mkdir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNestPinoConfig(configService: {
|
||||
get<T = string>(key: string, defaultValue?: T): T;
|
||||
}) {
|
||||
const nodeEnv = configService.get<string>("NODE_ENV", "development");
|
||||
const logLevel = configService.get<string>("LOG_LEVEL", "info");
|
||||
const appName = configService.get<string>("APP_NAME", "customer-portal-bff");
|
||||
|
||||
if (nodeEnv === "production") {
|
||||
const mkdir = await getMkdir();
|
||||
if (mkdir) {
|
||||
try {
|
||||
await mkdir("logs", { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pinoConfig: Record<string, unknown> = {
|
||||
level: logLevel,
|
||||
name: appName,
|
||||
base: {
|
||||
service: appName,
|
||||
environment: nodeEnv,
|
||||
pid: typeof process !== "undefined" ? process.pid : 0,
|
||||
},
|
||||
timestamp: true,
|
||||
redact: {
|
||||
paths: [
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"password",
|
||||
"password2",
|
||||
"token",
|
||||
"secret",
|
||||
"jwt",
|
||||
"apiKey",
|
||||
"params.password",
|
||||
"params.password2",
|
||||
"params.secret",
|
||||
"params.token",
|
||||
],
|
||||
remove: true,
|
||||
},
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
bindings: () => ({}),
|
||||
},
|
||||
serializers: {
|
||||
req: (req: { method?: string; url?: string; remoteAddress?: string; remotePort?: number }) => ({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
remoteAddress: req.remoteAddress,
|
||||
remotePort: req.remotePort,
|
||||
}),
|
||||
res: (res: { statusCode: number }) => ({ statusCode: res.statusCode }),
|
||||
err: (err: { constructor: { name: string }; message: string; stack?: string; code?: string; status?: number }) => ({
|
||||
type: err.constructor.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
...(err.code && { code: err.code }),
|
||||
...(err.status && { status: err.status }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (nodeEnv === "development") {
|
||||
(pinoConfig as any).transport = {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "yyyy-mm-dd HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
singleLine: false,
|
||||
hideObject: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (nodeEnv === "production") {
|
||||
(pinoConfig as any).transport = {
|
||||
targets: [
|
||||
{ target: "pino/file", level: logLevel, options: { destination: 1 } },
|
||||
{
|
||||
target: "pino/file",
|
||||
level: "info",
|
||||
options: { destination: join("logs", `${appName}-combined.log`), mkdir: true },
|
||||
},
|
||||
{
|
||||
target: "pino/file",
|
||||
level: "error",
|
||||
options: { destination: join("logs", `${appName}-error.log`), mkdir: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pinoHttp: {
|
||||
...(pinoConfig as any),
|
||||
genReqId: (req: any, res: any) => {
|
||||
const existingIdHeader = req.headers?.["x-correlation-id"];
|
||||
const existingId = Array.isArray(existingIdHeader) ? existingIdHeader[0] : existingIdHeader;
|
||||
if (existingId) return existingId;
|
||||
const correlationId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
res.setHeader?.("x-correlation-id", correlationId);
|
||||
return correlationId;
|
||||
},
|
||||
customLogLevel: (_req: any, res: any, err?: unknown) => {
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) return "warn";
|
||||
if (res.statusCode >= 500 || err) return "error";
|
||||
return "silent" as any;
|
||||
},
|
||||
customSuccessMessage: () => "",
|
||||
customErrorMessage: (req: any, res: any, err: { message?: string }) => {
|
||||
const method = req.method ?? "";
|
||||
const url = req.url ?? "";
|
||||
return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { DEFAULT_LOG_CONFIG, formatLogEntry, sanitizeLogData } from "./logger.config.js";
|
||||
import type { ILogger, LoggerOptions } from "./logger.interface.js";
|
||||
|
||||
/**
|
||||
* Create a cross-platform Pino-based logger that implements ILogger
|
||||
* Works in Node and browser environments
|
||||
*/
|
||||
export function createPinoLogger(options: LoggerOptions = {}): ILogger {
|
||||
const level = options.level ?? DEFAULT_LOG_CONFIG.level;
|
||||
const service = options.service ?? DEFAULT_LOG_CONFIG.service;
|
||||
const environment = options.environment ?? DEFAULT_LOG_CONFIG.environment;
|
||||
|
||||
// Context that flows with the logger instance
|
||||
let correlationId: string | undefined = options.context?.correlationId;
|
||||
let userId: string | undefined = options.context?.userId;
|
||||
let requestId: string | undefined = options.context?.requestId;
|
||||
|
||||
// Configure pino for both Node and browser
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
const pinoLogger = pino({
|
||||
level,
|
||||
name: service,
|
||||
base: {
|
||||
service,
|
||||
environment,
|
||||
},
|
||||
// Pretty output only in development for Node; browsers format via console
|
||||
...(isBrowser
|
||||
? { browser: { asObject: true } }
|
||||
: {}),
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
bindings: () => ({}),
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"password",
|
||||
"password2",
|
||||
"token",
|
||||
"secret",
|
||||
"jwt",
|
||||
"apiKey",
|
||||
"params.password",
|
||||
"params.password2",
|
||||
"params.secret",
|
||||
"params.token",
|
||||
],
|
||||
remove: true,
|
||||
},
|
||||
});
|
||||
|
||||
function withContext(data?: unknown): Record<string, unknown> | undefined {
|
||||
if (data == null) return undefined;
|
||||
const sanitized = sanitizeLogData(data);
|
||||
return {
|
||||
...(correlationId ? { correlationId } : {}),
|
||||
...(userId ? { userId } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
data: sanitized,
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const api: ILogger = {
|
||||
debug(message, data) {
|
||||
pinoLogger.debug(withContext(data), message);
|
||||
},
|
||||
info(message, data) {
|
||||
pinoLogger.info(withContext(data), message);
|
||||
},
|
||||
warn(message, data) {
|
||||
pinoLogger.warn(withContext(data), message);
|
||||
},
|
||||
error(message, data) {
|
||||
pinoLogger.error(withContext(data), message);
|
||||
},
|
||||
trace(message, data) {
|
||||
pinoLogger.trace(withContext(data), message);
|
||||
},
|
||||
|
||||
logApiCall(endpoint, method, status, duration, data) {
|
||||
pinoLogger.info(
|
||||
withContext({ endpoint, method, status, duration: `${duration}ms`, ...(data ? { data } : {}) }),
|
||||
`API ${method} ${endpoint}`
|
||||
);
|
||||
},
|
||||
logUserAction(user, action, data) {
|
||||
pinoLogger.info(withContext({ userId: user, action, ...(data ? { data } : {}) }), "User action");
|
||||
},
|
||||
logError(error, context, data) {
|
||||
pinoLogger.error(
|
||||
withContext({
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
...(context ? { context } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
`Error${context ? ` in ${context}` : ""}: ${error.message}`
|
||||
);
|
||||
},
|
||||
logRequest(req, data) {
|
||||
pinoLogger.info(withContext({ req, ...(data ? { data } : {}) }), "Request");
|
||||
},
|
||||
logResponse(res, data) {
|
||||
pinoLogger.info(withContext({ res, ...(data ? { data } : {} ) }), "Response");
|
||||
},
|
||||
|
||||
setCorrelationId(id) {
|
||||
correlationId = id;
|
||||
},
|
||||
setUserId(id) {
|
||||
userId = id;
|
||||
},
|
||||
setRequestId(id) {
|
||||
requestId = id;
|
||||
},
|
||||
|
||||
child(context) {
|
||||
const child = pinoLogger.child(context);
|
||||
const childLogger = createPinoLogger({
|
||||
level,
|
||||
service,
|
||||
environment,
|
||||
context: {
|
||||
correlationId,
|
||||
userId,
|
||||
requestId,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
// Bind methods to use child pino instance
|
||||
// We cannot replace the underlying pino instance easily, so we wrap methods
|
||||
return {
|
||||
...childLogger,
|
||||
debug(message, data) {
|
||||
child.debug(withContext(data), message);
|
||||
},
|
||||
info(message, data) {
|
||||
child.info(withContext(data), message);
|
||||
},
|
||||
warn(message, data) {
|
||||
child.warn(withContext(data), message);
|
||||
},
|
||||
error(message, data) {
|
||||
child.error(withContext(data), message);
|
||||
},
|
||||
trace(message, data) {
|
||||
child.trace(withContext(data), message);
|
||||
},
|
||||
} as ILogger;
|
||||
},
|
||||
|
||||
async flush() {
|
||||
// Flushing is typically relevant in Node streams; browsers are no-ops
|
||||
try {
|
||||
if (typeof (pinoLogger as unknown as { flush?: () => void }).flush === "function") {
|
||||
(pinoLogger as unknown as { flush?: () => void }).flush?.();
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
// Default singleton for convenience
|
||||
let defaultLogger: ILogger | undefined;
|
||||
export function getSharedLogger(): ILogger {
|
||||
if (!defaultLogger) {
|
||||
defaultLogger = createPinoLogger();
|
||||
}
|
||||
return defaultLogger;
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,9 @@
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"removeComments": false
|
||||
"removeComments": false,
|
||||
"moduleResolution": "node",
|
||||
"module": "CommonJS"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
|
||||
@ -1,179 +1,334 @@
|
||||
#!/bin/bash
|
||||
|
||||
#!/usr/bin/env bash
|
||||
# 🔧 Development Environment Manager
|
||||
# Manages development services with clean, organized structure
|
||||
# Clean, portable helper for local dev services & apps
|
||||
|
||||
set -e
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Configuration
|
||||
########################################
|
||||
# Config (override via env if you like)
|
||||
########################################
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
COMPOSE_FILE="$PROJECT_ROOT/docker/dev/docker-compose.yml"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
PROJECT_NAME="portal-dev"
|
||||
PROJECT_ROOT="${PROJECT_ROOT:-"$(cd "$SCRIPT_DIR/../.." && pwd)"}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-"$PROJECT_ROOT/docker/dev/docker-compose.yml"}"
|
||||
ENV_FILE="${ENV_FILE:-"$PROJECT_ROOT/.env"}"
|
||||
ENV_EXAMPLE_FILE="${ENV_EXAMPLE_FILE:-"$PROJECT_ROOT/.env.example"}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-portal-dev}"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
DB_USER_DEFAULT="dev"
|
||||
DB_NAME_DEFAULT="portal_dev"
|
||||
DB_WAIT_SECS="${DB_WAIT_SECS:-30}"
|
||||
|
||||
log() { echo -e "${GREEN}[DEV] $1${NC}"; }
|
||||
warn() { echo -e "${YELLOW}[DEV] $1${NC}"; }
|
||||
error() { echo -e "${RED}[DEV] ERROR: $1${NC}"; exit 1; }
|
||||
NEXT_PORT_DEFAULT=3000
|
||||
BFF_PORT_DEFAULT=4000
|
||||
|
||||
# Change to project root
|
||||
cd "$PROJECT_ROOT"
|
||||
########################################
|
||||
# Colors (fallback if tput missing)
|
||||
########################################
|
||||
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
|
||||
GREEN="$(tput setaf 2)"
|
||||
YELLOW="$(tput setaf 3)"
|
||||
RED="$(tput setaf 1)"
|
||||
NC="$(tput sgr0)"
|
||||
else
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
fi
|
||||
|
||||
# Start development services
|
||||
log() { echo -e "${GREEN}[DEV] $*${NC}"; }
|
||||
warn() { echo -e "${YELLOW}[DEV] $*${NC}"; }
|
||||
fail() { echo -e "${RED}[DEV] ERROR: $*${NC}"; exit 1; }
|
||||
|
||||
trap 'fail "Command failed (exit $?) at line $LINENO. See logs above."' ERR
|
||||
|
||||
########################################
|
||||
# Docker Compose wrapper (v2 & v1)
|
||||
########################################
|
||||
detect_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
echo "docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
echo "docker-compose"
|
||||
else
|
||||
fail "Docker Compose not found. Install Docker Desktop or docker-compose."
|
||||
fi
|
||||
}
|
||||
COMPOSE_BIN="$(detect_compose)"
|
||||
|
||||
compose() {
|
||||
# shellcheck disable=SC2086
|
||||
eval $COMPOSE_BIN -f "$COMPOSE_FILE" -p "$PROJECT_NAME" "$@"
|
||||
}
|
||||
|
||||
########################################
|
||||
# Preflight checks
|
||||
########################################
|
||||
preflight() {
|
||||
command -v docker >/dev/null 2>&1 || fail "Docker is required."
|
||||
[ -f "$COMPOSE_FILE" ] || fail "Compose file not found: $COMPOSE_FILE"
|
||||
|
||||
# Suggest Docker running if ps fails
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
fail "Docker daemon not reachable. Is Docker running?"
|
||||
fi
|
||||
|
||||
# pnpm required for app tasks
|
||||
if [[ "${1:-}" == "apps" || "${1:-}" == "migrate" ]]; then
|
||||
command -v pnpm >/dev/null 2>&1 || fail "pnpm is required for app commands."
|
||||
fi
|
||||
}
|
||||
|
||||
########################################
|
||||
# Env handling
|
||||
########################################
|
||||
ensure_env() {
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
warn "Environment file not found at $ENV_FILE"
|
||||
if [ -f "$ENV_EXAMPLE_FILE" ]; then
|
||||
log "Creating .env from example..."
|
||||
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
warn "Please edit $ENV_FILE with your actual values."
|
||||
else
|
||||
warn "No .env.example found at $ENV_EXAMPLE_FILE. Creating empty .env..."
|
||||
: > "$ENV_FILE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
load_env_exported() {
|
||||
# Export so child processes see env (compose, pnpm etc.)
|
||||
set +u
|
||||
set -a
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE" || true
|
||||
set +a
|
||||
set -u
|
||||
}
|
||||
|
||||
########################################
|
||||
# Helpers
|
||||
########################################
|
||||
services_running() {
|
||||
compose ps | grep -q "Up"
|
||||
}
|
||||
|
||||
wait_for_postgres() {
|
||||
local user="${POSTGRES_USER:-$DB_USER_DEFAULT}"
|
||||
local db="${POSTGRES_DB:-$DB_NAME_DEFAULT}"
|
||||
local timeout="$DB_WAIT_SECS"
|
||||
|
||||
log "⏳ Waiting for database ($db) to be ready (timeout: ${timeout}s)..."
|
||||
local elapsed=0
|
||||
local step=2
|
||||
until compose exec -T postgres pg_isready -U "$user" -d "$db" >/dev/null 2>&1; do
|
||||
sleep "$step"
|
||||
elapsed=$((elapsed + step))
|
||||
if (( elapsed >= timeout )); then
|
||||
fail "Database failed to become ready within ${timeout}s"
|
||||
fi
|
||||
done
|
||||
log "✅ Database is ready!"
|
||||
}
|
||||
|
||||
kill_by_port() {
|
||||
local port="$1"
|
||||
# Prefer lsof on macOS; fall back to fuser on Linux
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
if lsof -tiTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
log " Killing process on port $port..."
|
||||
lsof -tiTCP:"$port" -sTCP:LISTEN | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
elif command -v fuser >/dev/null 2>&1; then
|
||||
if fuser -n tcp "$port" >/dev/null 2>&1; then
|
||||
log " Killing process on port $port..."
|
||||
fuser -k -n tcp "$port" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
warn "Neither lsof nor fuser found; skipping port cleanup for $port."
|
||||
fi
|
||||
}
|
||||
|
||||
########################################
|
||||
# Commands
|
||||
########################################
|
||||
start_services() {
|
||||
log "🚀 Starting development services..."
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
warn "Environment file not found at $ENV_FILE"
|
||||
log "Creating from template..."
|
||||
cp .env.example .env
|
||||
warn "Please edit .env with your actual values"
|
||||
fi
|
||||
|
||||
# Start PostgreSQL and Redis
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d postgres redis
|
||||
|
||||
# Wait for database
|
||||
log "⏳ Waiting for database to be ready..."
|
||||
timeout=30
|
||||
while [ $timeout -gt 0 ]; do
|
||||
if docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" exec -T postgres pg_isready -U dev -d portal_dev 2>/dev/null; then
|
||||
log "✅ Database is ready!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
timeout=$((timeout - 2))
|
||||
done
|
||||
|
||||
if [ $timeout -eq 0 ]; then
|
||||
error "Database failed to start within 30 seconds"
|
||||
fi
|
||||
|
||||
log "✅ Development services are running!"
|
||||
log "🔗 Database: postgresql://dev:dev@localhost:5432/portal_dev"
|
||||
log "🔗 Redis: redis://localhost:6379"
|
||||
preflight "start"
|
||||
cd "$PROJECT_ROOT"
|
||||
ensure_env
|
||||
load_env_exported
|
||||
|
||||
log "🚀 Starting development services..."
|
||||
compose up -d postgres redis
|
||||
wait_for_postgres
|
||||
|
||||
local next="${NEXT_PORT:-$NEXT_PORT_DEFAULT}"
|
||||
local bff="${BFF_PORT:-$BFF_PORT_DEFAULT}"
|
||||
log "✅ Development services are running!"
|
||||
log "🔗 Database: postgresql://${POSTGRES_USER:-$DB_USER_DEFAULT}:${POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-dev}}@localhost:5432/${POSTGRES_DB:-$DB_NAME_DEFAULT}"
|
||||
log "🔗 Redis: redis://localhost:6379"
|
||||
log "🔗 BFF API (expected): http://localhost:${bff}/api"
|
||||
log "🔗 Frontend (expected): http://localhost:${next}"
|
||||
}
|
||||
|
||||
# Start with admin tools
|
||||
start_with_tools() {
|
||||
log "🛠️ Starting development services with admin tools..."
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" --profile tools up -d
|
||||
|
||||
log "🔗 Database Admin: http://localhost:8080"
|
||||
log "🔗 Redis Commander: http://localhost:8081"
|
||||
preflight "tools"
|
||||
cd "$PROJECT_ROOT"
|
||||
ensure_env
|
||||
load_env_exported
|
||||
|
||||
log "🛠️ Starting development services with admin tools..."
|
||||
compose --profile tools up -d
|
||||
wait_for_postgres
|
||||
log "🔗 Database Admin: http://localhost:8080"
|
||||
log "🔗 Redis Commander: http://localhost:8081"
|
||||
}
|
||||
|
||||
# Stop services
|
||||
stop_services() {
|
||||
log "⏹️ Stopping development services..."
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down
|
||||
log "✅ Services stopped"
|
||||
preflight "stop"
|
||||
cd "$PROJECT_ROOT"
|
||||
log "⏹️ Stopping development services..."
|
||||
compose down --remove-orphans
|
||||
log "✅ Services stopped"
|
||||
}
|
||||
|
||||
# Show status
|
||||
show_status() {
|
||||
log "📊 Development Services Status:"
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps
|
||||
preflight "status"
|
||||
cd "$PROJECT_ROOT"
|
||||
log "📊 Development Services Status:"
|
||||
compose ps
|
||||
}
|
||||
|
||||
# Show logs
|
||||
show_logs() {
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs -f "${@:2}"
|
||||
preflight "logs"
|
||||
cd "$PROJECT_ROOT"
|
||||
# Pass-through any service names after "logs"
|
||||
# e.g. ./dev.sh logs postgres redis
|
||||
compose logs -f --tail=100 "${@:2}"
|
||||
}
|
||||
|
||||
cleanup_dev() {
|
||||
log "🧹 Cleaning up all development processes and ports..."
|
||||
|
||||
# Pull ports from env if present; include common defaults
|
||||
local ports=()
|
||||
ports+=("${NEXT_PORT:-$NEXT_PORT_DEFAULT}")
|
||||
ports+=("${BFF_PORT:-$BFF_PORT_DEFAULT}")
|
||||
ports+=(5555) # Prisma Studio default
|
||||
|
||||
for p in "${ports[@]}"; do
|
||||
kill_by_port "$p"
|
||||
done
|
||||
|
||||
# Kill common dev processes by name
|
||||
pkill -f "next dev" 2>/dev/null && log " Stopped Next.js dev server" || true
|
||||
pkill -f "nest start --watch" 2>/dev/null && log " Stopped NestJS watch server" || true
|
||||
pkill -f "next-server" 2>/dev/null && log " Stopped Next.js server process" || true
|
||||
pkill -f "pnpm.*--parallel.*dev" 2>/dev/null && log " Stopped parallel dev processes" || true
|
||||
pkill -f "prisma studio" 2>/dev/null && log " Stopped Prisma Studio" || true
|
||||
|
||||
sleep 1
|
||||
log "✅ Development cleanup completed"
|
||||
}
|
||||
|
||||
# Start apps (services + local development)
|
||||
start_apps() {
|
||||
log "🚀 Starting development services and applications..."
|
||||
|
||||
# Start services if not running
|
||||
if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps | grep -q "Up"; then
|
||||
start_services
|
||||
fi
|
||||
|
||||
log "Starting development applications..."
|
||||
# Export root env so both apps can read from central .env
|
||||
set -a
|
||||
source "$ENV_FILE" 2>/dev/null || true
|
||||
set +a
|
||||
|
||||
# Build shared package first (required by both apps)
|
||||
log "🔨 Building shared package..."
|
||||
pnpm --filter @customer-portal/shared build
|
||||
|
||||
# Build BFF first to ensure dist directory exists for watch mode
|
||||
log "🔨 Building BFF for initial setup..."
|
||||
cd "$PROJECT_ROOT/apps/bff" && pnpm tsc -p tsconfig.build.json
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Show startup information
|
||||
log "🎯 Starting development applications..."
|
||||
log "🔗 BFF API: http://localhost:${BFF_PORT:-4000}/api"
|
||||
log "🔗 Frontend: http://localhost:${NEXT_PORT:-3000}"
|
||||
log "🔗 Database: postgresql://dev:dev@localhost:5432/portal_dev"
|
||||
log "🔗 Redis: redis://localhost:6379"
|
||||
log "📚 API Docs: http://localhost:${BFF_PORT:-4000}/api/docs"
|
||||
log "Starting apps with hot-reload..."
|
||||
preflight "apps"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Prisma Studio can be started manually with: pnpm db:studio
|
||||
cleanup_dev
|
||||
|
||||
# Start apps (portal + bff) with hot reload in parallel
|
||||
pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev
|
||||
if ! services_running; then
|
||||
start_services
|
||||
fi
|
||||
|
||||
load_env_exported
|
||||
|
||||
# Build shared package first
|
||||
log "🔨 Building shared package..."
|
||||
pnpm --filter @customer-portal/shared build
|
||||
|
||||
# Build BFF before watch (ensures dist exists)
|
||||
log "🔨 Building BFF for initial setup..."
|
||||
(cd "$PROJECT_ROOT/apps/bff" && pnpm tsc -p tsconfig.build.json)
|
||||
|
||||
local next="${NEXT_PORT:-$NEXT_PORT_DEFAULT}"
|
||||
local bff="${BFF_PORT:-$BFF_PORT_DEFAULT}"
|
||||
log "🎯 Starting development applications..."
|
||||
log "🔗 BFF API: http://localhost:${bff}/api"
|
||||
log "🔗 Frontend: http://localhost:${next}"
|
||||
log "🔗 Database: postgresql://${POSTGRES_USER:-$DB_USER_DEFAULT}:${POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-dev}}@localhost:5432/${POSTGRES_DB:-$DB_NAME_DEFAULT}"
|
||||
log "🔗 Redis: redis://localhost:6379"
|
||||
log "📚 API Docs: http://localhost:${bff}/api/docs"
|
||||
log "Starting apps with hot-reload..."
|
||||
# Prisma Studio can be started manually with: pnpm db:studio
|
||||
|
||||
# Run portal + bff in parallel with hot reload
|
||||
pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev
|
||||
}
|
||||
|
||||
# Reset environment
|
||||
reset_env() {
|
||||
log "🔄 Resetting development environment..."
|
||||
stop_services
|
||||
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v
|
||||
docker system prune -f
|
||||
log "✅ Development environment reset"
|
||||
preflight "reset"
|
||||
cd "$PROJECT_ROOT"
|
||||
log "🔄 Resetting development environment..."
|
||||
compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
log "✅ Development environment reset"
|
||||
}
|
||||
|
||||
# Run database migrations
|
||||
migrate_db() {
|
||||
log "🗄️ Running database migrations..."
|
||||
|
||||
if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps postgres | grep -q "Up"; then
|
||||
error "Database service not running. Run 'pnpm dev:start' first"
|
||||
fi
|
||||
|
||||
pnpm db:migrate
|
||||
log "✅ Database migrations completed"
|
||||
preflight "migrate"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if ! compose ps postgres | grep -q "Up"; then
|
||||
fail "Database service not running. Run '$0 start' or '$0 apps' first."
|
||||
fi
|
||||
|
||||
load_env_exported
|
||||
log "🗄️ Running database migrations..."
|
||||
pnpm db:migrate
|
||||
log "✅ Database migrations completed"
|
||||
}
|
||||
|
||||
# Main function
|
||||
case "${1:-help}" in
|
||||
"start") start_services ;;
|
||||
"stop") stop_services ;;
|
||||
"restart") stop_services && start_services ;;
|
||||
"status") show_status ;;
|
||||
"logs") show_logs "$@" ;;
|
||||
"tools") start_with_tools ;;
|
||||
"apps") start_apps ;;
|
||||
"migrate") migrate_db ;;
|
||||
"reset") reset_env ;;
|
||||
"help"|*)
|
||||
echo "🔧 Development Environment Manager"
|
||||
echo ""
|
||||
echo "Usage: $0 {command}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start - Start development services (PostgreSQL + Redis)"
|
||||
echo " stop - Stop all development services"
|
||||
echo " restart - Restart all services"
|
||||
echo " status - Show service status"
|
||||
echo " logs - Show service logs"
|
||||
echo " tools - Start services with admin tools"
|
||||
echo " apps - Start services + run development apps"
|
||||
echo " migrate - Run database migrations"
|
||||
echo " reset - Reset development environment"
|
||||
echo " help - Show this help"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
usage() {
|
||||
cat <<EOF
|
||||
🔧 Development Environment Manager
|
||||
|
||||
Usage: $0 {command}
|
||||
|
||||
Commands:
|
||||
start - Start dev services (PostgreSQL + Redis)
|
||||
stop - Stop all dev services
|
||||
restart - Restart dev services
|
||||
status - Show service status
|
||||
logs - Tail logs (optionally: specify services, e.g. '$0 logs postgres redis')
|
||||
tools - Start services with admin tools
|
||||
apps - Start services + run dev apps (auto-cleanup)
|
||||
cleanup - Clean up local dev processes and ports
|
||||
migrate - Run database migrations
|
||||
reset - Reset development environment
|
||||
help - Show this help
|
||||
|
||||
Environment overrides:
|
||||
PROJECT_ROOT, COMPOSE_FILE, ENV_FILE, ENV_EXAMPLE_FILE, PROJECT_NAME
|
||||
POSTGRES_USER, POSTGRES_DB, POSTGRES_PASSWORD, DB_WAIT_SECS
|
||||
NEXT_PORT, BFF_PORT
|
||||
EOF
|
||||
}
|
||||
|
||||
########################################
|
||||
# Main
|
||||
########################################
|
||||
cmd="${1:-help}"
|
||||
case "$cmd" in
|
||||
start) start_services ;;
|
||||
stop) stop_services ;;
|
||||
restart) stop_services; start_services ;;
|
||||
status) show_status ;;
|
||||
logs) show_logs "$@" ;;
|
||||
tools) start_with_tools ;;
|
||||
apps) start_apps ;;
|
||||
cleanup) cleanup_dev ;;
|
||||
migrate) migrate_db ;;
|
||||
reset) reset_env ;;
|
||||
help|*) usage; exit 0 ;;
|
||||
esac
|
||||
Loading…
x
Reference in New Issue
Block a user