feat: complete Phase 1 foundation scaffold
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages - Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification) - Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores - Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready - Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy - Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure - i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next - Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils - CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks) - Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md - Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts - Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
This commit is contained in:
23
packages/utils/package.json
Normal file
23
packages/utils/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@investplay/utils",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "InvestPlay shared utility functions",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"rimraf": "^6.0.0"
|
||||
}
|
||||
}
|
||||
34
packages/utils/src/constants.ts
Normal file
34
packages/utils/src/constants.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const SUPPORTED_LOCALES = ["en", "el"] as const;
|
||||
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: SupportedLocale = "en";
|
||||
|
||||
export const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
EUR: "€",
|
||||
USD: "$",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CNY: "¥",
|
||||
BTC: "₿",
|
||||
ETH: "Ξ",
|
||||
};
|
||||
|
||||
export const AGE_BRACKETS = [
|
||||
{ min: 13, max: 15, label: "13-15" },
|
||||
{ min: 16, max: 18, label: "16-18" },
|
||||
{ min: 19, max: 24, label: "19-24" },
|
||||
{ min: 25, max: 35, label: "25-35" },
|
||||
{ min: 36, max: 50, label: "36-50" },
|
||||
{ min: 51, max: 99, label: "51+" },
|
||||
] as const;
|
||||
|
||||
export const LESSON_TIME_MIN = 5;
|
||||
export const LESSON_TIME_MAX = 45;
|
||||
|
||||
export const MAX_DAILY_AI_MESSAGES = 50;
|
||||
|
||||
export const MAX_MONTHLY_TOKENS = {
|
||||
managed: 1_000_000,
|
||||
byok: 10_000_000,
|
||||
disabled: 0,
|
||||
} as const;
|
||||
52
packages/utils/src/env.ts
Normal file
52
packages/utils/src/env.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Parse a string environment variable into a boolean.
|
||||
* Accepts: "true", "1", "yes" (case-insensitive) → true; everything else → false.
|
||||
*/
|
||||
export function parseEnvBoolean(value: string | undefined, defaultValue = false): boolean {
|
||||
if (value === undefined || value === null) return defaultValue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string environment variable into a number with optional bounds.
|
||||
*/
|
||||
export function parseEnvNumber(
|
||||
value: string | undefined,
|
||||
defaultValue: number,
|
||||
options?: { min?: number; max?: number },
|
||||
): number {
|
||||
if (value === undefined || value === null || value.trim() === "") return defaultValue;
|
||||
const parsed = Number(value);
|
||||
if (Number.isNaN(parsed)) return defaultValue;
|
||||
if (options?.min !== undefined && parsed < options.min) return options.min;
|
||||
if (options?.max !== undefined && parsed > options.max) return options.max;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string environment variable into one of the allowed enum values.
|
||||
* Returns the default value if the input doesn't match any allowed value.
|
||||
*/
|
||||
export function parseEnvEnum<T extends string>(
|
||||
value: string | undefined,
|
||||
allowedValues: readonly T[],
|
||||
defaultValue: T,
|
||||
): T {
|
||||
if (value === undefined || value === null) return defaultValue;
|
||||
const normalized = value.trim() as T;
|
||||
if (allowedValues.includes(normalized)) return normalized;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated string environment variable into an array of strings.
|
||||
* Trims each entry and filters out empty strings.
|
||||
*/
|
||||
export function parseEnvArray(value: string | undefined, defaultValue: string[] = []): string[] {
|
||||
if (value === undefined || value === null || value.trim() === "") return defaultValue;
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
95
packages/utils/src/formatting.ts
Normal file
95
packages/utils/src/formatting.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
locale = "en",
|
||||
currency = "EUR",
|
||||
): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return `${currency} ${amount.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
date: Date | string,
|
||||
locale = "en",
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...options,
|
||||
}).format(d);
|
||||
} catch {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRelativeTime(
|
||||
date: Date | string,
|
||||
locale = "en",
|
||||
): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
if (diffSeconds < 60) return rtf.format(-diffSeconds, "second");
|
||||
if (diffMinutes < 60) return rtf.format(-diffMinutes, "minute");
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour");
|
||||
if (diffDays < 30) return rtf.format(-diffDays, "day");
|
||||
return formatDate(d, locale);
|
||||
} catch {
|
||||
if (diffDays > 0) return `${diffDays}d ago`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m ago`;
|
||||
return "just now";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale = "en",
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, options).format(value);
|
||||
} catch {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPercentage(
|
||||
value: number,
|
||||
locale = "en",
|
||||
decimals = 1,
|
||||
): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value / 100);
|
||||
} catch {
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength).trimEnd() + "...";
|
||||
}
|
||||
26
packages/utils/src/index.ts
Normal file
26
packages/utils/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
emailSchema,
|
||||
passwordSchema,
|
||||
tenantNameSchema,
|
||||
localeSchema,
|
||||
dailyMessageLimitSchema,
|
||||
simulationTradeSchema,
|
||||
} from "./validation.js";
|
||||
export {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
truncateText,
|
||||
} from "./formatting.js";
|
||||
export {
|
||||
SUPPORTED_LOCALES,
|
||||
DEFAULT_LOCALE,
|
||||
CURRENCY_SYMBOLS,
|
||||
AGE_BRACKETS,
|
||||
LESSON_TIME_MIN,
|
||||
LESSON_TIME_MAX,
|
||||
MAX_DAILY_AI_MESSAGES,
|
||||
MAX_MONTHLY_TOKENS,
|
||||
} from "./constants.js";
|
||||
44
packages/utils/src/validation.ts
Normal file
44
packages/utils/src/validation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be under 255 characters")
|
||||
.transform((v) => v.toLowerCase().trim());
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be under 128 characters")
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.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 tenantNameSchema = z
|
||||
.string()
|
||||
.min(2, "Tenant name must be at least 2 characters")
|
||||
.max(100, "Tenant name must be under 100 characters")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9\s\-'&.]+$/,
|
||||
"Tenant name contains invalid characters",
|
||||
);
|
||||
|
||||
export const localeSchema = z.enum(["en", "el"]);
|
||||
|
||||
export const dailyMessageLimitSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Minimum 1 message per day")
|
||||
.max(500, "Maximum 500 messages per day");
|
||||
|
||||
export const simulationTradeSchema = z.object({
|
||||
assetName: z.string().min(1, "Asset name is required"),
|
||||
type: z.enum(["buy", "sell"]),
|
||||
quantity: z.number().positive("Quantity must be positive"),
|
||||
price: z.number().positive("Price must be positive"),
|
||||
reason: z.string().max(500, "Reason must be under 500 characters").optional(),
|
||||
});
|
||||
11
packages/utils/tsconfig.json
Normal file
11
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", ".turbo"]
|
||||
}
|
||||
Reference in New Issue
Block a user