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

- 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:
Lefteris Notas
2026-06-12 19:32:57 +03:00
parent fa71c60cfc
commit e75f913f1c
226 changed files with 17547 additions and 0 deletions

View 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"
}
}

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

View 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() + "...";
}

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

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

View 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"]
}