+
-
- {isLoading ?
: (
- products.map(product => (
-
-
-
-
{product.name[language]}
-
{product.priceDisplay[language]}
-
-
- {t('artisanProductsEdit')}
-
-
- ))
+
+ {isLoading ? (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ {products.map((product, index) => (
+
+
+
+
+
+
{product.name[language]}
+
{product.priceDisplay[language]}
+
+
+ {t('artisanProductsEdit')}
+
+
+ ))}
+
)}
diff --git a/server/.eslintrc.js b/server/.eslintrc.js
new file mode 100644
index 0000000..a1cd83f
--- /dev/null
+++ b/server/.eslintrc.js
@@ -0,0 +1,26 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint/eslint-plugin'],
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ root: true,
+ env: {
+ node: true,
+ jest: true,
+ },
+ ignorePatterns: ['.eslintrc.js'],
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
+
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..9957104
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,200 @@
+# CraftsHK AI Backend
+
+NestJS backend server for the CraftsHK AI application.
+
+## Features
+
+- **Authentication**: JWT-based auth with user registration, login, and profile management
+- **Database**: SQLite for development, PostgreSQL for production
+- **API Endpoints**: Products, Crafts, Events, Orders, Messages, AI generation
+- **Security**:
+ - Password hashing with bcrypt
+ - JWT tokens with role-based access control
+ - Helmet.js security headers
+ - Rate limiting (100 requests/15 minutes)
+ - Input validation and sanitization
+ - Structured logging with Winston
+ - Health check endpoints
+ - Sentry error tracking
+
+## Quick Start
+
+```bash
+# Install dependencies
+npm install
+
+# Start development server
+npm run start:dev
+
+# Build for production
+npm run build
+
+# Start production server
+npm run start:prod
+```
+
+## Environment Configuration
+
+Create a `.env.local` file in the project root or `server/` directory:
+
+```env
+# Node Environment
+NODE_ENV=development
+PORT=3001
+HOST=0.0.0.0
+
+# Database (SQLite - default for development)
+DATABASE_TYPE=sqlite
+DATABASE_PATH=database.sqlite
+
+# Database (PostgreSQL - for production)
+# DATABASE_TYPE=postgres
+# DATABASE_HOST=localhost
+# DATABASE_PORT=5432
+# DATABASE_USER=postgres
+# DATABASE_PASSWORD=your_secure_password
+# DATABASE_NAME=craftshk
+# DATABASE_SSL=false
+
+# Authentication (IMPORTANT: Change in production!)
+JWT_SECRET=your-super-secret-key-at-least-32-chars
+JWT_EXPIRES_IN=7d
+
+# Security & Rate Limiting
+RATE_LIMIT_WINDOW_MS=900000 # 15 minutes in milliseconds
+RATE_LIMIT_MAX_REQUESTS=100 # Max requests per window
+LOG_LEVEL=info # Logging level: error, warn, info, debug
+
+# Error Tracking (Optional)
+# SENTRY_DSN=https://xxx@sentry.io/xxx
+
+# AI Services
+GEMINI_API_KEY=your_gemini_api_key
+```
+
+## Database Migrations
+
+For production with PostgreSQL:
+
+```bash
+# Generate a new migration
+npm run migration:generate -- src/migrations/MigrationName
+
+# Run migrations
+npm run migration:run
+
+# Revert last migration
+npm run migration:revert
+
+# Show migration status
+npm run migration:show
+```
+
+## API Endpoints
+
+### Authentication
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/auth/register` | Register a new user |
+| POST | `/api/auth/login` | Login and get JWT token |
+| GET | `/api/auth/profile` | Get current user profile (auth required) |
+| PUT | `/api/auth/profile` | Update user profile (auth required) |
+| POST | `/api/auth/change-password` | Change password (auth required) |
+| POST | `/api/auth/forgot-password` | Request password reset |
+| POST | `/api/auth/reset-password` | Reset password with token |
+
+### Resources
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/crafts` | List all crafts |
+| GET | `/api/crafts/:id` | Get craft by ID |
+| GET | `/api/products` | List all products |
+| GET | `/api/products/:id` | Get product by ID |
+| GET | `/api/events` | List all events |
+| GET | `/api/events/:id` | Get event by ID |
+| GET | `/api/orders` | List all orders |
+| GET | `/api/messages` | List message threads |
+
+### AI Features
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/ai/generate-image` | Generate AI image |
+| POST | `/api/ai/generate-tryon` | Generate try-on image |
+| POST | `/api/translation/suggest` | Get translation suggestions |
+
+### Health Checks
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/health` | Full health check (database, memory) |
+| GET | `/health/ready` | Readiness probe (for Kubernetes/Cloud Run) |
+| GET | `/health/live` | Liveness probe (for Kubernetes/Cloud Run) |
+
+## User Roles
+
+- `user` - Regular users who explore crafts
+- `artisan` - Craft makers who can manage products
+- `admin` - System administrators
+
+## Project Structure
+
+```
+server/
+├── src/
+│ ├── auth/ # Authentication module
+│ │ ├── decorators/ # Custom decorators (@CurrentUser, @Roles, etc.)
+│ │ ├── dto/ # Data transfer objects
+│ │ ├── guards/ # JWT and role guards
+│ │ ├── strategies/ # Passport JWT strategy
+│ │ ├── auth.controller.ts
+│ │ ├── auth.module.ts
+│ │ └── auth.service.ts
+│ ├── entities/ # TypeORM entities
+│ ├── crafts/ # Crafts module
+│ ├── products/ # Products module
+│ ├── events/ # Events module
+│ ├── orders/ # Orders module
+│ ├── messages/ # Messages module
+│ ├── ai/ # AI generation module
+│ ├── database/ # Database configuration
+│ ├── health/ # Health check endpoints
+│ ├── logger/ # Winston logging configuration
+│ ├── migrations/ # Database migrations
+│ └── main.ts # Application entry point
+├── typeorm.config.ts # TypeORM CLI configuration
+└── package.json
+```
+
+## Security Features
+
+### Built-in Protection
+
+1. **Authentication & Authorization**
+ - JWT tokens with configurable expiration
+ - Role-based access control (RBAC)
+ - Password hashing with bcrypt
+
+2. **Request Protection**
+ - Rate limiting: 100 requests per 15 minutes (configurable)
+ - Input validation and sanitization with class-validator
+ - Helmet.js security headers (XSS, clickjacking, etc.)
+
+3. **Monitoring & Logging**
+ - Winston structured logging
+ - Sentry error tracking (optional)
+ - Health check endpoints for monitoring
+
+### Production Checklist
+
+- [ ] Change `JWT_SECRET` to a long, random string (64+ characters)
+- [ ] Enable HTTPS in production
+- [ ] Never commit `.env` files to version control
+- [ ] Enable SSL for PostgreSQL connections
+- [ ] Configure `SENTRY_DSN` for error tracking
+- [ ] Set appropriate `RATE_LIMIT_MAX_REQUESTS` for your use case
+- [ ] Review and adjust security headers in `main.ts`
+- [ ] Set `LOG_LEVEL=warn` or `LOG_LEVEL=error` in production
+
diff --git a/server/package-lock.json b/server/package-lock.json
index c0f26f9..0bb0d82 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -12,25 +12,39 @@
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0",
+ "@nestjs/terminus": "^11.0.0",
+ "@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.1",
+ "@sentry/node": "^10.32.1",
+ "bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"class-transformer": "^0.5.1",
- "class-validator": "^0.14.0",
+ "class-validator": "^0.14.3",
"fontkit": "^2.0.4",
+ "helmet": "^8.1.0",
+ "nest-winston": "^1.10.2",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "pg": "^8.16.3",
"reflect-metadata": "^0.2.1",
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.6",
- "typeorm": "^0.3.17"
+ "typeorm": "^0.3.17",
+ "winston": "^3.19.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
+ "@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.8",
"@types/node": "^20.19.23",
+ "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
@@ -45,7 +59,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
- "typescript": "^4.9.5"
+ "typescript": "^5.9.3"
}
},
"node_modules/@angular-devkit/core": {
@@ -208,6 +222,23 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/@apm-js-collab/code-transformer": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz",
+ "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@apm-js-collab/tracing-hooks": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz",
+ "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@apm-js-collab/code-transformer": "^0.8.0",
+ "debug": "^4.4.1",
+ "module-details-from-path": "^1.0.4"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -769,6 +800,17 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@dabh/diagnostics": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
+ "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@so-ric/colorspace": "^1.1.6",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -1818,6 +1860,29 @@
}
}
},
+ "node_modules/@nestjs/jwt": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
+ "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/jsonwebtoken": "9.0.10",
+ "jsonwebtoken": "9.0.3"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
+ }
+ },
+ "node_modules/@nestjs/passport": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
+ "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@nestjs/common": "^10.0.0 || ^11.0.0",
+ "passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
+ }
+ },
"node_modules/@nestjs/platform-express": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz",
@@ -1863,6 +1928,76 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@nestjs/terminus": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.0.0.tgz",
+ "integrity": "sha512-c55LOo9YGovmQHtFUMa/vDaxGZ2cglMTZejqgHREaApt/GArTfgYYGwhRXPLq8ZwiQQlLuYB+79e9iA8mlDSLA==",
+ "license": "MIT",
+ "dependencies": {
+ "boxen": "5.1.2",
+ "check-disk-space": "3.4.0"
+ },
+ "peerDependencies": {
+ "@grpc/grpc-js": "*",
+ "@grpc/proto-loader": "*",
+ "@mikro-orm/core": "*",
+ "@mikro-orm/nestjs": "*",
+ "@nestjs/axios": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "@nestjs/common": "^10.0.0 || ^11.0.0",
+ "@nestjs/core": "^10.0.0 || ^11.0.0",
+ "@nestjs/microservices": "^10.0.0 || ^11.0.0",
+ "@nestjs/mongoose": "^11.0.0",
+ "@nestjs/sequelize": "^10.0.0 || ^11.0.0",
+ "@nestjs/typeorm": "^10.0.0 || ^11.0.0",
+ "@prisma/client": "*",
+ "mongoose": "*",
+ "reflect-metadata": "0.1.x || 0.2.x",
+ "rxjs": "7.x",
+ "sequelize": "*",
+ "typeorm": "*"
+ },
+ "peerDependenciesMeta": {
+ "@grpc/grpc-js": {
+ "optional": true
+ },
+ "@grpc/proto-loader": {
+ "optional": true
+ },
+ "@mikro-orm/core": {
+ "optional": true
+ },
+ "@mikro-orm/nestjs": {
+ "optional": true
+ },
+ "@nestjs/axios": {
+ "optional": true
+ },
+ "@nestjs/microservices": {
+ "optional": true
+ },
+ "@nestjs/mongoose": {
+ "optional": true
+ },
+ "@nestjs/sequelize": {
+ "optional": true
+ },
+ "@nestjs/typeorm": {
+ "optional": true
+ },
+ "@prisma/client": {
+ "optional": true
+ },
+ "mongoose": {
+ "optional": true
+ },
+ "sequelize": {
+ "optional": true
+ },
+ "typeorm": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@nestjs/testing": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz",
@@ -1891,6 +2026,17 @@
}
}
},
+ "node_modules/@nestjs/throttler": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
+ "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
+ "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
+ "reflect-metadata": "^0.1.13 || ^0.2.0"
+ }
+ },
"node_modules/@nestjs/typeorm": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
@@ -2078,6 +2224,498 @@
"npm": ">=5.0.0"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
+ "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/context-async-hooks": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz",
+ "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
+ "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz",
+ "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "import-in-the-middle": "^2.0.0",
+ "require-in-the-middle": "^8.0.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-amqplib": {
+ "version": "0.55.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.55.0.tgz",
+ "integrity": "sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-connect": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.52.0.tgz",
+ "integrity": "sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
+ "@types/connect": "3.4.38"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-dataloader": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.0.tgz",
+ "integrity": "sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-express": {
+ "version": "0.57.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.0.tgz",
+ "integrity": "sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-fs": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.28.0.tgz",
+ "integrity": "sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-generic-pool": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.52.0.tgz",
+ "integrity": "sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-graphql": {
+ "version": "0.56.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.56.0.tgz",
+ "integrity": "sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-hapi": {
+ "version": "0.55.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.0.tgz",
+ "integrity": "sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-http": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.208.0.tgz",
+ "integrity": "sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/instrumentation": "0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0",
+ "forwarded-parse": "2.1.2"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-ioredis": {
+ "version": "0.56.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.56.0.tgz",
+ "integrity": "sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/redis-common": "^0.38.2"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-kafkajs": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.0.tgz",
+ "integrity": "sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.30.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-knex": {
+ "version": "0.53.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.0.tgz",
+ "integrity": "sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.33.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-koa": {
+ "version": "0.57.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.0.tgz",
+ "integrity": "sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.36.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-lru-memoizer": {
+ "version": "0.53.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.0.tgz",
+ "integrity": "sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mongodb": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.61.0.tgz",
+ "integrity": "sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mongoose": {
+ "version": "0.55.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.0.tgz",
+ "integrity": "sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mysql": {
+ "version": "0.54.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.54.0.tgz",
+ "integrity": "sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@types/mysql": "2.15.27"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-mysql2": {
+ "version": "0.55.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.0.tgz",
+ "integrity": "sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.33.0",
+ "@opentelemetry/sql-common": "^0.41.2"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-pg": {
+ "version": "0.61.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.0.tgz",
+ "integrity": "sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.34.0",
+ "@opentelemetry/sql-common": "^0.41.2",
+ "@types/pg": "8.15.6",
+ "@types/pg-pool": "2.0.6"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-redis": {
+ "version": "0.57.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.0.tgz",
+ "integrity": "sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/redis-common": "^0.38.2",
+ "@opentelemetry/semantic-conventions": "^1.27.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-tedious": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.27.0.tgz",
+ "integrity": "sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@types/tedious": "^4.0.14"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation-undici": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.19.0.tgz",
+ "integrity": "sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/semantic-conventions": "^1.24.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.7.0"
+ }
+ },
+ "node_modules/@opentelemetry/redis-common": {
+ "version": "0.38.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz",
+ "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
+ "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.38.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz",
+ "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@opentelemetry/sql-common": {
+ "version": "0.41.2",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz",
+ "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "^2.0.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0"
+ }
+ },
"node_modules/@paralleldrive/cuid2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@@ -2111,6 +2749,116 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@prisma/instrumentation": {
+ "version": "6.19.0",
+ "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.19.0.tgz",
+ "integrity": "sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/instrumentation": ">=0.52.0 <1"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.8"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.32.1",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.32.1.tgz",
+ "integrity": "sha512-PH2ldpSJlhqsMj2vCTyU0BI2Fx1oIDhm7Izo5xFALvjVCS0gmlqHt1udu6YlKn8BtpGH6bGzssvv5APrk+OdPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/node": {
+ "version": "10.32.1",
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.32.1.tgz",
+ "integrity": "sha512-oxlybzt8QW0lx/QaEj1DcvZDRXkgouewFelu/10dyUwv5So3YvipfvWInda+yMLmn25OggbloDQ0gyScA2jU3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^2.2.0",
+ "@opentelemetry/core": "^2.2.0",
+ "@opentelemetry/instrumentation": "^0.208.0",
+ "@opentelemetry/instrumentation-amqplib": "0.55.0",
+ "@opentelemetry/instrumentation-connect": "0.52.0",
+ "@opentelemetry/instrumentation-dataloader": "0.26.0",
+ "@opentelemetry/instrumentation-express": "0.57.0",
+ "@opentelemetry/instrumentation-fs": "0.28.0",
+ "@opentelemetry/instrumentation-generic-pool": "0.52.0",
+ "@opentelemetry/instrumentation-graphql": "0.56.0",
+ "@opentelemetry/instrumentation-hapi": "0.55.0",
+ "@opentelemetry/instrumentation-http": "0.208.0",
+ "@opentelemetry/instrumentation-ioredis": "0.56.0",
+ "@opentelemetry/instrumentation-kafkajs": "0.18.0",
+ "@opentelemetry/instrumentation-knex": "0.53.0",
+ "@opentelemetry/instrumentation-koa": "0.57.0",
+ "@opentelemetry/instrumentation-lru-memoizer": "0.53.0",
+ "@opentelemetry/instrumentation-mongodb": "0.61.0",
+ "@opentelemetry/instrumentation-mongoose": "0.55.0",
+ "@opentelemetry/instrumentation-mysql": "0.54.0",
+ "@opentelemetry/instrumentation-mysql2": "0.55.0",
+ "@opentelemetry/instrumentation-pg": "0.61.0",
+ "@opentelemetry/instrumentation-redis": "0.57.0",
+ "@opentelemetry/instrumentation-tedious": "0.27.0",
+ "@opentelemetry/instrumentation-undici": "0.19.0",
+ "@opentelemetry/resources": "^2.2.0",
+ "@opentelemetry/sdk-trace-base": "^2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.37.0",
+ "@prisma/instrumentation": "6.19.0",
+ "@sentry/core": "10.32.1",
+ "@sentry/node-core": "10.32.1",
+ "@sentry/opentelemetry": "10.32.1",
+ "import-in-the-middle": "^2",
+ "minimatch": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/node-core": {
+ "version": "10.32.1",
+ "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.32.1.tgz",
+ "integrity": "sha512-w56rxdBanBKc832zuwnE+zNzUQ19fPxfHEtOhK8JGPu3aSwQYcIxwz9z52lOx3HN7k/8Fj5694qlT3x/PokhRw==",
+ "license": "MIT",
+ "dependencies": {
+ "@apm-js-collab/tracing-hooks": "^0.3.1",
+ "@sentry/core": "10.32.1",
+ "@sentry/opentelemetry": "10.32.1",
+ "import-in-the-middle": "^2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/instrumentation": ">=0.57.1 <1",
+ "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.37.0"
+ }
+ },
+ "node_modules/@sentry/opentelemetry": {
+ "version": "10.32.1",
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.32.1.tgz",
+ "integrity": "sha512-YLssSz5Y+qPvufrh2cDaTXDoXU8aceOhB+YTjT8/DLF6SOj7Tzen52aAcjNaifawaxEsLCC8O+B+A2iA+BllvA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.32.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.37.0"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -2138,6 +2886,16 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@so-ric/colorspace": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
+ "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
+ "license": "MIT",
+ "dependencies": {
+ "color": "^5.0.2",
+ "text-hex": "1.0.x"
+ }
+ },
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -2260,6 +3018,16 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2275,7 +3043,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2405,6 +3172,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -2419,16 +3196,82 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/mysql": {
+ "version": "2.15.27",
+ "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz",
+ "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/passport": {
+ "version": "1.0.17",
+ "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
+ "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
+ "node_modules/@types/passport-jwt": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
+ "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/jsonwebtoken": "*",
+ "@types/passport-strategy": "*"
+ }
+ },
+ "node_modules/@types/passport-strategy": {
+ "version": "0.2.38",
+ "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
+ "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*",
+ "@types/passport": "*"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.15.6",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
+ "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/pg-pool": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz",
+ "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/pg": "*"
+ }
+ },
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -2503,6 +3346,21 @@
"@types/superagent": "*"
}
},
+ "node_modules/@types/tedious": {
+ "version": "4.0.14",
+ "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
+ "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/triple-beam": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
+ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
+ "license": "MIT"
+ },
"node_modules/@types/validator": {
"version": "13.15.3",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz",
@@ -2930,7 +3788,6 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
- "devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -2939,6 +3796,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/acorn-import-attributes": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
+ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
"node_modules/acorn-import-phases": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
@@ -3060,6 +3926,15 @@
"ajv": "^8.8.2"
}
},
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -3240,6 +4115,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3424,6 +4305,29 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/bcrypt/node_modules/node-addon-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
+ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -3505,6 +4409,57 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/boxen": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
+ "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-align": "^3.0.0",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.1.0",
+ "cli-boxes": "^2.2.1",
+ "string-width": "^4.2.2",
+ "type-fest": "^0.20.2",
+ "widest-line": "^3.1.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -3923,6 +4878,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/check-disk-space": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz",
+ "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -3987,7 +4951,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
@@ -3997,14 +4960,14 @@
"license": "MIT"
},
"node_modules/class-validator": {
- "version": "0.14.2",
- "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
- "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
+ "version": "0.14.3",
+ "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
+ "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
"dependencies": {
- "@types/validator": "^13.11.8",
+ "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
- "validator": "^13.9.0"
+ "validator": "^13.15.20"
}
},
"node_modules/clean-stack": {
@@ -4017,6 +4980,18 @@
"node": ">=6"
}
},
+ "node_modules/cli-boxes": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
+ "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -4128,6 +5103,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/color": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
+ "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^3.1.3",
+ "color-string": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4146,6 +5134,27 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/color-string": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
+ "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-string/node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -4156,6 +5165,27 @@
"color-support": "bin.js"
}
},
+ "node_modules/color/node_modules/color-convert": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
+ "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/color/node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4689,6 +5719,12 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
+ "node_modules/enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -5365,6 +6401,12 @@
"bser": "2.1.1"
}
},
+ "node_modules/fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
+ "license": "MIT"
+ },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -5582,6 +6624,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
+ "license": "MIT"
+ },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
@@ -5734,6 +6782,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/forwarded-parse": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
+ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
+ "license": "MIT"
+ },
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -6241,6 +7295,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -6391,6 +7454,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/import-in-the-middle": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.1.tgz",
+ "integrity": "sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "acorn-import-attributes": "^1.9.5",
+ "cjs-module-lexer": "^1.2.2",
+ "module-details-from-path": "^1.0.3"
+ }
+ },
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -7604,6 +8679,28 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -7616,12 +8713,12 @@
}
},
"node_modules/jws": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
- "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
- "jwa": "^2.0.0",
+ "jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
@@ -7645,6 +8742,12 @@
"node": ">=6"
}
},
+ "node_modules/kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
+ "license": "MIT"
+ },
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7714,6 +8817,42 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -7728,6 +8867,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -7745,6 +8890,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/logform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
+ "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "1.6.0",
+ "@types/triple-beam": "^1.3.2",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/logform/node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8041,7 +9212,6 @@
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
- "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -8290,6 +9460,12 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
+ "node_modules/module-details-from-path": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
+ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8350,6 +9526,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nest-winston": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.10.2.tgz",
+ "integrity": "sha512-Z9IzL/nekBOF/TEwBHUJDiDPMaXUcFquUQOFavIRet6xF0EbuWnOzslyN/ksgzG+fITNgXhMdrL/POp9SdaFxA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-safe-stringify": "^2.1.1"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
+ "winston": "^3.0.0"
+ }
+ },
"node_modules/node-abi": {
"version": "3.77.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
@@ -8430,6 +9619,17 @@
"node": ">= 10.12.0"
}
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-gyp/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -8605,6 +9805,15 @@
"wrappy": "1"
}
},
+ "node_modules/one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fn.name": "1.x.x"
+ }
+ },
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -8784,6 +9993,42 @@
"node": ">= 0.8"
}
},
+ "node_modules/passport": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
+ "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "passport-strategy": "1.x.x",
+ "pause": "0.0.1",
+ "utils-merge": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jaredhanson"
+ }
+ },
+ "node_modules/passport-jwt": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
+ "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0",
+ "passport-strategy": "^1.0.0"
+ }
+ },
+ "node_modules/passport-strategy": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
+ "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8858,6 +10103,100 @@
"node": ">=8"
}
},
+ "node_modules/pause": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
+ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
+ },
+ "node_modules/pg": {
+ "version": "8.16.3",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+ "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.9.1",
+ "pg-pool": "^3.10.1",
+ "pg-protocol": "^1.10.3",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.2.7"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+ "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+ "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+ "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8976,6 +10315,45 @@
"node": ">= 0.4"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -9330,6 +10708,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-in-the-middle": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
+ "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "module-details-from-path": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=9.3.0 || >=8.10.0 <9.0.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -9520,6 +10911,15 @@
],
"license": "MIT"
},
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9966,6 +11366,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -10046,6 +11455,15 @@
"license": "ISC",
"optional": true
},
+ "node_modules/stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -10579,6 +11997,12 @@
"node": "*"
}
},
+ "node_modules/text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
+ "license": "MIT"
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -10689,6 +12113,15 @@
"tree-kill": "cli.js"
}
},
+ "node_modules/triple-beam": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
+ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@@ -10919,7 +12352,6 @@
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
- "dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -11113,9 +12545,9 @@
}
},
"node_modules/typescript": {
- "version": "4.9.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
- "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
@@ -11123,7 +12555,7 @@
"tsserver": "bin/tsserver"
},
"engines": {
- "node": ">=4.2.0"
+ "node": ">=14.17"
}
},
"node_modules/uglify-js": {
@@ -11168,7 +12600,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
@@ -11322,9 +12753,9 @@
}
},
"node_modules/validator": {
- "version": "13.15.15",
- "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
- "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
+ "version": "13.15.26",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
+ "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -11552,6 +12983,63 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
+ "node_modules/widest-line": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
+ "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/winston": {
+ "version": "3.19.0",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
+ "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "^1.6.0",
+ "@dabh/diagnostics": "^2.0.8",
+ "async": "^3.2.3",
+ "is-stream": "^2.0.0",
+ "logform": "^2.7.0",
+ "one-time": "^1.0.0",
+ "readable-stream": "^3.4.0",
+ "safe-stable-stringify": "^2.3.1",
+ "stack-trace": "0.0.x",
+ "triple-beam": "^1.3.0",
+ "winston-transport": "^4.9.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston-transport": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
+ "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
+ "license": "MIT",
+ "dependencies": {
+ "logform": "^2.7.0",
+ "readable-stream": "^3.6.2",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston/node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/server/package.json b/server/package.json
index cc7cc4a..717cb3d 100644
--- a/server/package.json
+++ b/server/package.json
@@ -17,32 +17,51 @@
"seed": "ts-node src/seed.ts",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
- "test:e2e": "jest --config ./test/jest-e2e.json"
+ "test:e2e": "jest --config ./test/jest-e2e.json",
+ "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d typeorm.config.ts",
+ "migration:generate": "npm run typeorm -- migration:generate",
+ "migration:run": "npm run typeorm -- migration:run",
+ "migration:revert": "npm run typeorm -- migration:revert",
+ "migration:show": "npm run typeorm -- migration:show"
},
"dependencies": {
"@google/genai": "^1.20.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
+ "@nestjs/jwt": "^11.0.2",
+ "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0",
+ "@nestjs/terminus": "^11.0.0",
+ "@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.1",
+ "@sentry/node": "^10.32.1",
+ "bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"class-transformer": "^0.5.1",
- "class-validator": "^0.14.0",
+ "class-validator": "^0.14.3",
"fontkit": "^2.0.4",
+ "helmet": "^8.1.0",
+ "nest-winston": "^1.10.2",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "pg": "^8.16.3",
"reflect-metadata": "^0.2.1",
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.6",
- "typeorm": "^0.3.17"
+ "typeorm": "^0.3.17",
+ "winston": "^3.19.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
+ "@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.8",
"@types/node": "^20.19.23",
+ "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
@@ -57,7 +76,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
- "typescript": "^4.9.5"
+ "typescript": "^5.9.3"
},
"jest": {
"moduleFileExtensions": [
diff --git a/server/src/admin/admin.service.ts b/server/src/admin/admin.service.ts
index ef53c87..462cbfa 100644
--- a/server/src/admin/admin.service.ts
+++ b/server/src/admin/admin.service.ts
@@ -1,13 +1,14 @@
-import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { Craft } from '../entities/craft.entity';
-import { Product } from '../entities/product.entity';
-import { Event } from '../entities/event.entity';
-import { Artisan } from '../entities/artisan.entity';
-import { Order } from '../entities/order.entity';
-import { MessageThread } from '../entities/message-thread.entity';
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+import { Craft } from "../entities/craft.entity";
+import { Product } from "../entities/product.entity";
+import { Event } from "../entities/event.entity";
+import { Artisan } from "../entities/artisan.entity";
+import { Order } from "../entities/order.entity";
+import { MessageThread } from "../entities/message-thread.entity";
+/* eslint-disable @typescript-eslint/no-var-requires */
// Import seed data from constants.cjs
const {
CRAFTS,
@@ -16,7 +17,8 @@ const {
ARTISANS,
ORDERS,
MESSAGE_THREADS,
-} = require('../../constants.cjs');
+} = require("../../constants.cjs");
+/* eslint-enable @typescript-eslint/no-var-requires */
@Injectable()
export class AdminService {
@@ -41,7 +43,7 @@ export class AdminService {
const count = await this.craftRepository.count();
if (count > 0) {
return {
- message: 'Database already has data',
+ message: "Database already has data",
counts: {
crafts: count,
products: await this.productRepository.count(),
@@ -62,7 +64,7 @@ export class AdminService {
await this.messageThreadRepository.save(MESSAGE_THREADS);
return {
- message: 'Database seeded successfully! 🌱',
+ message: "Database seeded successfully! 🌱",
counts: {
crafts: await this.craftRepository.count(),
products: await this.productRepository.count(),
@@ -74,7 +76,7 @@ export class AdminService {
};
} catch (error) {
return {
- message: 'Error seeding database',
+ message: "Error seeding database",
error: error.message,
};
}
@@ -94,7 +96,7 @@ export class AdminService {
return this.seedDatabase();
} catch (error) {
return {
- message: 'Error reseeding database',
+ message: "Error reseeding database",
error: error.message,
};
}
diff --git a/server/src/ai/ai.service.ts b/server/src/ai/ai.service.ts
index 9542994..85db3ed 100644
--- a/server/src/ai/ai.service.ts
+++ b/server/src/ai/ai.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import { GoogleGenAI, Type } from '@google/genai';
import { getGeminiApiKey } from '../config/gemini.config';
import { getDoubaoConfig, isDoubaoConfigured } from '../config/doubao.config';
@@ -10,6 +10,7 @@ import type { TranslationOption, TranslationStrategy } from '../types/translatio
@Injectable()
export class AiService {
+ private readonly logger = new Logger(AiService.name);
async getMahjongTranslationSuggestions(input: string): Promise
{
const MAX_CHARACTER_LENGTH = 4;
const MAX_OPTIONS = 3;
@@ -194,20 +195,20 @@ Respond strictly as JSON matching the provided schema.`;
const publicDir = path.join(__dirname, '..', '..', '..', 'public');
const filePath = path.join(publicDir, imageUrl);
- console.log('Reading image file from:', filePath);
+ this.logger.log(`Reading image file from: ${filePath}`);
if (!fs.existsSync(filePath)) {
throw new Error(`Image file not found: ${filePath}`);
}
const imageBuffer = fs.readFileSync(filePath);
- console.log('Image file loaded, size:', imageBuffer.length, 'bytes');
+ this.logger.log(`Image file loaded, size: ${imageBuffer.length} bytes`);
return imageBuffer.toString('base64');
}
// If it's an HTTP(S) URL, fetch it
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
- console.log('Fetching image from URL:', imageUrl);
+ this.logger.log(`Fetching image from URL: ${imageUrl}`);
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
@@ -263,14 +264,14 @@ Respond strictly as JSON matching the provided schema.`;
if (isMahjong && hasChinesePrompt) {
// Extract only Chinese characters from the prompt (in case it includes pronunciation/explanation)
const chineseOnly = userPrompt.match(/[\u3400-\u9FFF]+/g)?.[0] || userPrompt;
- console.log('Generating mahjong tile reference image with Chinese text:', chineseOnly);
+ this.logger.log(`Generating mahjong tile reference image with Chinese text: ${chineseOnly}`);
referenceImage = generateMahjongTileReference(chineseOnly);
// DEBUG: Log reference image details
- console.log('Reference image generated:');
- console.log('- Format: PNG (base64 encoded)');
- console.log('- Size:', Math.round(referenceImage.length / 1024), 'KB');
- console.log('- Data URL length:', referenceImage.length, 'characters');
+ this.logger.log('Reference image generated:');
+ this.logger.log('- Format: PNG (base64 encoded)');
+ this.logger.log(`- Size: ${Math.round(referenceImage.length / 1024)} KB`);
+ this.logger.log(`- Data URL length: ${referenceImage.length} characters`);
// Enhance the prompt to use the reference image with EXPLICIT instructions
enhancedPrompt = `A hand-carved traditional Hong Kong mahjong tile with Chinese character(s) engraved vertically on it.
@@ -285,22 +286,22 @@ CRITICAL REQUIREMENTS:
7. Beautiful lighting that highlights the depth of the engraving
Reference image shows the correct Chinese characters to engrave. DO NOT change, simplify, or substitute any characters.`;
- console.log('Enhanced mahjong prompt for Chinese text:', chineseOnly);
+ this.logger.log(`Enhanced mahjong prompt for Chinese text: ${chineseOnly}`);
}
const fullPrompt = isMahjong && hasChinesePrompt
? enhancedPrompt
: `A high-quality, artistic image of a modern interpretation of a traditional Hong Kong craft: ${craftName}. The design is inspired by: "${userPrompt}". Focus on intricate details and beautiful lighting.`;
- console.log('=== Backend AI Service - Full Prompt ===');
- console.log('Craft Name:', craftName);
- console.log('User Prompt:', userPrompt);
- console.log('Is Mahjong:', isMahjong);
- console.log('Has Chinese:', hasChinesePrompt);
- console.log('Has Reference Image:', !!referenceImage);
- console.log('Full Prompt Sent to AI:');
- console.log(fullPrompt);
- console.log('========================================');
+ this.logger.log('=== Backend AI Service - Full Prompt ===');
+ this.logger.log(`Craft Name: ${craftName}`);
+ this.logger.log(`User Prompt: ${userPrompt}`);
+ this.logger.log(`Is Mahjong: ${isMahjong}`);
+ this.logger.log(`Has Chinese: ${hasChinesePrompt}`);
+ this.logger.log(`Has Reference Image: ${!!referenceImage}`);
+ this.logger.log('Full Prompt Sent to AI:');
+ this.logger.log(fullPrompt);
+ this.logger.log('========================================');
const hasChineseInput =
this.containsChineseCharacters(userPrompt) || this.containsChineseCharacters(craftName);
@@ -311,7 +312,7 @@ Reference image shows the correct Chinese characters to engrave. DO NOT change,
const imageUrl = await generateDoubaoImage(fullPrompt, doubaoConfig);
return { imageUrl };
} catch (doubaoError) {
- console.error('Error generating image with Doubao:', doubaoError);
+ this.logger.error('Error generating image with Doubao:', doubaoError);
if (!aiClient) {
throw doubaoError;
}
@@ -336,7 +337,7 @@ Reference image shows the correct Chinese characters to engrave. DO NOT change,
});
} else if (referenceImageUrl) {
// Add user-provided reference image (e.g., cheongsam for pattern draft)
- console.log('Adding user-provided reference image to prompt');
+ this.logger.log('Adding user-provided reference image to prompt');
const refBase64 = await this.imageUrlToBase64(referenceImageUrl);
const refMimeType = this.getMimeType(referenceImageUrl);
promptParts.push({
@@ -375,7 +376,7 @@ Remember: Character accuracy from the reference image is MORE IMPORTANT than art
throw new Error('AI failed to generate an image. Please try again later.');
} catch (error) {
- console.error('Error generating image with AI provider:', error);
+ this.logger.error('Error generating image with AI provider:', error);
if (error instanceof Error) {
throw new Error(error.message.includes('Doubao') ? error.message : `Gemini API Error: ${error.message}`);
}
@@ -403,9 +404,9 @@ Remember: Character accuracy from the reference image is MORE IMPORTANT than art
}
const filePath = path.join(debugDir, filename);
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
- console.log(`Saved debug image: ${filePath}`);
+ this.logger.log(`Saved debug image: ${filePath}`);
} catch (err) {
- console.error('Failed to save debug image', filename, err);
+ this.logger.error('Failed to save debug image', filename, err);
}
};
const aiClient = this.ai;
@@ -415,16 +416,16 @@ Remember: Character accuracy from the reference image is MORE IMPORTANT than art
}
try {
- console.log('=== Try-On Image Generation ===');
- console.log('Craft Name:', craftName);
- console.log('User Prompt:', userPrompt);
- console.log('Face Image URL:', faceImageUrl);
- console.log('Existing Cheongsam Image:', existingCheongsamImageUrl ? 'Yes' : 'No');
+ this.logger.log('=== Try-On Image Generation ===');
+ this.logger.log(`Craft Name: ${craftName}`);
+ this.logger.log(`User Prompt: ${userPrompt}`);
+ this.logger.log(`Face Image URL: ${faceImageUrl}`);
+ this.logger.log(`Existing Cheongsam Image: ${existingCheongsamImageUrl ? 'Yes' : 'No'}`);
// Step 1: Generate a full-body model with the reference face
const faceBase64 = await this.imageUrlToBase64(faceImageUrl);
const faceMimeType = this.getMimeType(faceImageUrl);
- console.log('Face image converted to base64, length:', faceBase64.length, 'MIME type:', faceMimeType);
+ this.logger.log(`Face image converted to base64, length: ${faceBase64.length}, MIME type: ${faceMimeType}`);
saveDebugImage(faceBase64, 'step1_face_input.jpg');
const step1Prompt = [
@@ -443,7 +444,7 @@ Remember: The face must be IDENTICAL to the reference image provided.`
},
];
- console.log('Step 1: Generating full-body model with reference face...');
+ this.logger.log('Step 1: Generating full-body model with reference face...');
const step1Response = await aiClient.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: step1Prompt,
@@ -454,7 +455,7 @@ Remember: The face must be IDENTICAL to the reference image provided.`
for (const part of step1Response.candidates[0].content.parts) {
if (part.inlineData) {
fullBodyImageBase64 = part.inlineData.data;
- console.log('Step 1: Full-body image generated successfully');
+ this.logger.log('Step 1: Full-body image generated successfully');
saveDebugImage(fullBodyImageBase64, 'step1_fullbody.jpg');
break;
}
@@ -470,13 +471,13 @@ Remember: The face must be IDENTICAL to the reference image provided.`
if (existingCheongsamImageUrl) {
// Use existing cheongsam image from concept mode
- console.log('Step 2: Using existing cheongsam image from concept mode');
+ this.logger.log('Step 2: Using existing cheongsam image from concept mode');
cheongsamImageBase64 = await this.imageUrlToBase64(existingCheongsamImageUrl);
saveDebugImage(cheongsamImageBase64, 'step2_cheongsam_input.jpg');
- console.log('Step 2: Existing cheongsam image loaded successfully');
+ this.logger.log('Step 2: Existing cheongsam image loaded successfully');
} else {
// Generate new cheongsam garment image
- console.log('Step 2: Generating cheongsam garment...');
+ this.logger.log('Step 2: Generating cheongsam garment...');
const cheongsamPrompt = `Create a professional product photo of an elegant ${craftName}. The cheongsam should feature:
${userPrompt ? `\nAdditional design notes: ${userPrompt}` : ''}`;
@@ -490,7 +491,7 @@ ${userPrompt ? `\nAdditional design notes: ${userPrompt}` : ''}`;
if (part.inlineData) {
cheongsamImageBase64 = part.inlineData.data;
saveDebugImage(cheongsamImageBase64, 'step2_cheongsam_generated.jpg');
- console.log('Step 2: Cheongsam garment generated successfully');
+ this.logger.log('Step 2: Cheongsam garment generated successfully');
break;
}
}
@@ -502,7 +503,7 @@ ${userPrompt ? `\nAdditional design notes: ${userPrompt}` : ''}`;
}
// Step 3: Combine the full-body model with the cheongsam
- console.log('Step 3: Combining model with cheongsam...');
+ this.logger.log('Step 3: Combining model with cheongsam...');
const step3Prompt = [
{
text: `You are given two images:
@@ -543,9 +544,9 @@ Do NOT just return the person's photo - you must show them WEARING the cheongsam
if (part.inlineData) {
const finalImageBase64 = part.inlineData.data;
saveDebugImage(finalImageBase64, 'step3_final_tryon.jpg');
- console.log('Step 3: Try-on image generated successfully');
- console.log('Final image preview (first 100 chars):', finalImageBase64.substring(0, 100));
- console.log('================================');
+ this.logger.log('Step 3: Try-on image generated successfully');
+ this.logger.log(`Final image preview (first 100 chars): ${finalImageBase64.substring(0, 100)}`);
+ this.logger.log('================================');
return { imageUrl: `data:image/jpeg;base64,${finalImageBase64}` };
}
}
@@ -553,7 +554,7 @@ Do NOT just return the person's photo - you must show them WEARING the cheongsam
throw new Error('Failed to generate final try-on image in step 3');
} catch (error) {
- console.error('Error in try-on image generation:', error);
+ this.logger.error('Error in try-on image generation:', error);
if (error instanceof Error) {
throw new Error(`Try-on generation failed: ${error.message}`);
}
@@ -682,11 +683,11 @@ Do NOT just return the person's photo - you must show them WEARING the cheongsam
userPrompt += `\n\nUse radicals/strokes from: ${GLYPH_LIBRARY.map(g => g.name).join(', ')}`;
userPrompt += `\nCanvas: 400x400px. Position elements creatively. Use 5-15 glyphs per layout.`;
- console.log('=== AI Text Lab Generation Prompt ===');
- console.log('Craft:', craftName);
- console.log('Mode:', mode);
- console.log('User Input:', userInput);
- console.log('=====================================');
+ this.logger.log('=== AI Text Lab Generation Prompt ===');
+ this.logger.log(`Craft: ${craftName}`);
+ this.logger.log(`Mode: ${mode}`);
+ this.logger.log(`User Input: ${userInput}`);
+ this.logger.log('=====================================');
const result = await aiClient.models.generateContent({
model: 'gemini-2.0-flash-exp',
@@ -706,11 +707,11 @@ Do NOT just return the person's photo - you must show them WEARING the cheongsam
const jsonText = text.replace(/```json\n?|```\n?/g, '').trim();
const responseJson = JSON.parse(jsonText);
- console.log('[Text Lab] Generated layouts:', responseJson?.layouts?.length || 0);
+ this.logger.log(`[Text Lab] Generated layouts: ${responseJson?.layouts?.length || 0}`);
return responseJson;
} catch (error) {
- console.error('Error generating text lab layouts:', error);
+ this.logger.error('Error generating text lab layouts:', error);
throw new Error(`Text lab generation failed: ${error.message}`);
}
}
diff --git a/server/src/app.module.ts b/server/src/app.module.ts
index 15c7ded..269f5c3 100644
--- a/server/src/app.module.ts
+++ b/server/src/app.module.ts
@@ -1,4 +1,6 @@
-import { Module, Controller, Get, OnModuleInit } from '@nestjs/common';
+import { Module, Controller, Get, OnModuleInit, Logger } from '@nestjs/common';
+import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
+import { APP_GUARD } from '@nestjs/core';
import { ProductsModule } from './products/products.module';
import { CraftsModule } from './crafts/crafts.module';
import { EventsModule } from './events/events.module';
@@ -8,7 +10,10 @@ import { DatabaseModule } from './database/database.module';
import { AiModule } from './ai/ai.module';
import { DebugModule } from './debug/debug.module';
import { AdminModule } from './admin/admin.module';
+import { AuthModule } from './auth/auth.module';
import { AdminService } from './admin/admin.service';
+import { LoggerModule } from './logger/logger.module';
+import { HealthModule } from './health/health.module';
@Controller()
export class AppController {
@@ -18,13 +23,24 @@ export class AppController {
message: 'CraftsHK AI Backend is running! 🚀',
version: '3.0.0',
endpoints: {
+ auth: {
+ register: '/api/auth/register (POST)',
+ login: '/api/auth/login (POST)',
+ profile: '/api/auth/profile (GET/PUT)',
+ changePassword: '/api/auth/change-password (POST)',
+ },
products: '/api/products',
crafts: '/api/crafts',
events: '/api/events',
orders: '/api/orders',
messages: '/api/messages',
admin: '/admin/seed (POST)',
- debug: '/debug (development only)'
+ debug: '/debug (development only)',
+ health: {
+ status: '/health (GET)',
+ ready: '/health/ready (GET)',
+ live: '/health/live (GET)',
+ },
}
};
}
@@ -32,7 +48,15 @@ export class AppController {
@Module({
imports: [
+ // Structured logging with Winston
+ LoggerModule,
+ // Rate limiting: 100 requests per 15 minutes per IP
+ ThrottlerModule.forRoot([{
+ ttl: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
+ limit: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
+ }]),
DatabaseModule,
+ AuthModule,
ProductsModule,
CraftsModule,
EventsModule,
@@ -41,24 +65,32 @@ export class AppController {
AiModule,
DebugModule,
AdminModule,
+ HealthModule,
],
controllers: [AppController],
- providers: [],
+ providers: [
+ {
+ provide: APP_GUARD,
+ useClass: ThrottlerGuard,
+ },
+ ],
})
export class AppModule implements OnModuleInit {
+ private readonly logger = new Logger(AppModule.name);
+
constructor(private readonly adminService: AdminService) {}
async onModuleInit() {
// Auto-seed database on startup if empty
- console.log('🔍 Checking database status...');
+ this.logger.log('🔍 Checking database status...');
try {
const result = await this.adminService.seedDatabase();
- console.log('✅ Database check complete:', result.message);
+ this.logger.log(`✅ Database check complete: ${result.message}`);
if (result.counts) {
- console.log('📊 Database counts:', result.counts);
+ this.logger.log(`📊 Database counts: ${JSON.stringify(result.counts)}`);
}
} catch (error) {
- console.error('❌ Error during database initialization:', error);
+ this.logger.error('❌ Error during database initialization:', error);
}
}
}
diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts
new file mode 100644
index 0000000..0a18f00
--- /dev/null
+++ b/server/src/auth/auth.controller.ts
@@ -0,0 +1,96 @@
+import {
+ Controller,
+ Post,
+ Get,
+ Put,
+ Body,
+ UseGuards,
+ HttpCode,
+ HttpStatus,
+} from '@nestjs/common';
+import { AuthService } from './auth.service';
+import {
+ RegisterDto,
+ LoginDto,
+ UpdateProfileDto,
+ ChangePasswordDto,
+ ForgotPasswordDto,
+ ResetPasswordDto,
+ AuthResponseDto,
+} from './dto/auth.dto';
+import { JwtAuthGuard } from './guards/jwt-auth.guard';
+import { Public } from './decorators/public.decorator';
+import { CurrentUser } from './decorators/current-user.decorator';
+import { User } from '../entities/user.entity';
+
+@Controller('api/auth')
+export class AuthController {
+ constructor(private readonly authService: AuthService) {}
+
+ @Public()
+ @Post('register')
+ async register(@Body() registerDto: RegisterDto): Promise {
+ return this.authService.register(registerDto);
+ }
+
+ @Public()
+ @Post('login')
+ @HttpCode(HttpStatus.OK)
+ async login(@Body() loginDto: LoginDto): Promise {
+ return this.authService.login(loginDto);
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Get('profile')
+ async getProfile(@CurrentUser() user: User): Promise {
+ return this.authService.getProfile(user.id);
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Put('profile')
+ async updateProfile(
+ @CurrentUser() user: User,
+ @Body() updateDto: UpdateProfileDto,
+ ): Promise<{ user: User }> {
+ const updatedUser = await this.authService.updateProfile(user.id, updateDto);
+ return { user: updatedUser };
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Post('change-password')
+ @HttpCode(HttpStatus.OK)
+ async changePassword(
+ @CurrentUser() user: User,
+ @Body() changePasswordDto: ChangePasswordDto,
+ ): Promise<{ message: string }> {
+ return this.authService.changePassword(user.id, changePasswordDto);
+ }
+
+ @Public()
+ @Post('forgot-password')
+ @HttpCode(HttpStatus.OK)
+ async forgotPassword(
+ @Body() forgotPasswordDto: ForgotPasswordDto,
+ ): Promise<{ message: string }> {
+ return this.authService.forgotPassword(forgotPasswordDto.email);
+ }
+
+ @Public()
+ @Post('reset-password')
+ @HttpCode(HttpStatus.OK)
+ async resetPassword(
+ @Body() resetPasswordDto: ResetPasswordDto,
+ ): Promise<{ message: string }> {
+ return this.authService.resetPassword(
+ resetPasswordDto.token,
+ resetPasswordDto.newPassword,
+ );
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Get('me')
+ async getCurrentUser(@CurrentUser() user: User): Promise {
+ return user;
+ }
+}
+
diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts
new file mode 100644
index 0000000..44afc5e
--- /dev/null
+++ b/server/src/auth/auth.module.ts
@@ -0,0 +1,33 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { JwtModule } from '@nestjs/jwt';
+import { PassportModule } from '@nestjs/passport';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { AuthController } from './auth.controller';
+import { AuthService } from './auth.service';
+import { JwtStrategy } from './strategies/jwt.strategy';
+import { JwtAuthGuard } from './guards/jwt-auth.guard';
+import { RolesGuard } from './guards/roles.guard';
+import { User } from '../entities/user.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([User]),
+ PassportModule.register({ defaultStrategy: 'jwt' }),
+ JwtModule.registerAsync({
+ imports: [ConfigModule],
+ useFactory: (configService: ConfigService) => ({
+ secret: configService.get('JWT_SECRET', 'craftshk-secret-key-change-in-production'),
+ signOptions: {
+ expiresIn: configService.get('JWT_EXPIRES_IN', '7d') as any,
+ },
+ }),
+ inject: [ConfigService],
+ }),
+ ],
+ controllers: [AuthController],
+ providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
+ exports: [AuthService, JwtAuthGuard, RolesGuard],
+})
+export class AuthModule {}
+
diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts
new file mode 100644
index 0000000..f93c32d
--- /dev/null
+++ b/server/src/auth/auth.service.spec.ts
@@ -0,0 +1,419 @@
+import { Test, TestingModule } from "@nestjs/testing";
+import { AuthService, JwtPayload } from "./auth.service";
+import { getRepositoryToken } from "@nestjs/typeorm";
+import { JwtService } from "@nestjs/jwt";
+import { ConfigService } from "@nestjs/config";
+import {
+ UnauthorizedException,
+ ConflictException,
+ BadRequestException,
+ NotFoundException,
+} from "@nestjs/common";
+import { User, UserRole } from "../entities/user.entity";
+import { Repository } from "typeorm";
+import * as bcrypt from "bcrypt";
+
+// Mock bcrypt at the module level
+jest.mock("bcrypt");
+
+describe("AuthService", () => {
+ let service: AuthService;
+ let userRepository: MockType>;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ let jwtService: JwtService;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ let configService: ConfigService;
+
+ // Mock repository type
+ type MockType = {
+ [P in keyof T]?: jest.Mock;
+ };
+
+ // Mock repository factory
+ const mockRepositoryFactory: () => MockType> = jest.fn(
+ () => ({
+ findOne: jest.fn(),
+ find: jest.fn(),
+ create: jest.fn(),
+ save: jest.fn(),
+ createQueryBuilder: jest.fn(() => ({
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn(),
+ })),
+ }),
+ );
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ AuthService,
+ {
+ provide: getRepositoryToken(User),
+ useFactory: mockRepositoryFactory,
+ },
+ {
+ provide: JwtService,
+ useValue: {
+ sign: jest.fn(() => "mock-jwt-token"),
+ },
+ },
+ {
+ provide: ConfigService,
+ useValue: {
+ get: jest.fn(() => "test-secret"),
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(AuthService);
+ userRepository = module.get(getRepositoryToken(User));
+ jwtService = module.get(JwtService);
+ configService = module.get(ConfigService);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("register", () => {
+ const registerDto = {
+ email: "test@example.com",
+ username: "testuser",
+ password: "password123",
+ displayName: "Test User",
+ role: UserRole.USER,
+ };
+
+ it("should register a new user successfully", async () => {
+ userRepository.findOne.mockResolvedValue(null);
+ const newUser = {
+ id: 1,
+ ...registerDto,
+ avatarUrl: null,
+ emailVerificationToken: "mock-token",
+ };
+ userRepository.create.mockReturnValue(newUser);
+ userRepository.save.mockResolvedValue(newUser);
+
+ const result = await service.register(registerDto);
+
+ expect(result).toEqual({
+ message: "Registration successful",
+ token: "mock-jwt-token",
+ user: {
+ id: 1,
+ email: registerDto.email,
+ username: registerDto.username,
+ role: registerDto.role,
+ displayName: registerDto.displayName,
+ avatarUrl: null,
+ },
+ });
+ expect(userRepository.findOne).toHaveBeenCalledWith({
+ where: [
+ { email: registerDto.email },
+ { username: registerDto.username },
+ ],
+ });
+ expect(userRepository.save).toHaveBeenCalled();
+ });
+
+ it("should throw ConflictException if email already exists", async () => {
+ userRepository.findOne.mockResolvedValue({
+ id: 1,
+ email: registerDto.email,
+ username: "otheruser",
+ });
+
+ await expect(service.register(registerDto)).rejects.toThrow(
+ new ConflictException("Email already registered"),
+ );
+ });
+
+ it("should throw ConflictException if username already exists", async () => {
+ userRepository.findOne.mockResolvedValue({
+ id: 1,
+ email: "other@example.com",
+ username: registerDto.username,
+ });
+
+ await expect(service.register(registerDto)).rejects.toThrow(
+ new ConflictException("Username already taken"),
+ );
+ });
+ });
+
+ describe("login", () => {
+ const loginDto = {
+ email: "test@example.com",
+ password: "password123",
+ };
+
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ username: "testuser",
+ password: "$2b$10$mockHashedPassword",
+ role: UserRole.USER,
+ displayName: "Test User",
+ avatarUrl: null,
+ isActive: true,
+ lastLoginAt: new Date(),
+ };
+
+ it("should login user successfully", async () => {
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(mockUser),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+ userRepository.save.mockResolvedValue(mockUser);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(true);
+
+ const result = await service.login(loginDto);
+
+ expect(result).toEqual({
+ message: "Login successful",
+ token: "mock-jwt-token",
+ user: {
+ id: mockUser.id,
+ email: mockUser.email,
+ username: mockUser.username,
+ role: mockUser.role,
+ displayName: mockUser.displayName,
+ avatarUrl: mockUser.avatarUrl,
+ },
+ });
+ });
+
+ it("should throw UnauthorizedException if user not found", async () => {
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(null),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+
+ await expect(service.login(loginDto)).rejects.toThrow(
+ new UnauthorizedException("Invalid email or password"),
+ );
+ });
+
+ it("should throw UnauthorizedException if user is inactive", async () => {
+ const inactiveUser = { ...mockUser, isActive: false };
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(inactiveUser),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+
+ await expect(service.login(loginDto)).rejects.toThrow(
+ new UnauthorizedException("Account is deactivated"),
+ );
+ });
+
+ it("should throw UnauthorizedException if password is invalid", async () => {
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(mockUser),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(false);
+
+ await expect(service.login(loginDto)).rejects.toThrow(
+ new UnauthorizedException("Invalid email or password"),
+ );
+ });
+ });
+
+ describe("getProfile", () => {
+ it("should return user profile successfully", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ username: "testuser",
+ role: UserRole.USER,
+ };
+ userRepository.findOne.mockResolvedValue(mockUser);
+
+ const result = await service.getProfile(1);
+
+ expect(result).toEqual(mockUser);
+ expect(userRepository.findOne).toHaveBeenCalledWith({
+ where: { id: 1 },
+ relations: ["artisan"],
+ });
+ });
+
+ it("should throw NotFoundException if user not found", async () => {
+ userRepository.findOne.mockResolvedValue(null);
+
+ await expect(service.getProfile(999)).rejects.toThrow(
+ new NotFoundException("User not found"),
+ );
+ });
+ });
+
+ describe("updateProfile", () => {
+ const updateDto = {
+ displayName: "Updated Name",
+ bio: "Updated bio",
+ };
+
+ it("should update user profile successfully", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ displayName: "Old Name",
+ };
+ const updatedUser = { ...mockUser, ...updateDto };
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userRepository.save.mockResolvedValue(updatedUser);
+
+ const result = await service.updateProfile(1, updateDto);
+
+ expect(result).toEqual(updatedUser);
+ expect(userRepository.save).toHaveBeenCalled();
+ });
+
+ it("should throw NotFoundException if user not found", async () => {
+ userRepository.findOne.mockResolvedValue(null);
+
+ await expect(service.updateProfile(999, updateDto)).rejects.toThrow(
+ new NotFoundException("User not found"),
+ );
+ });
+ });
+
+ describe("changePassword", () => {
+ const changePasswordDto = {
+ currentPassword: "oldPassword123",
+ newPassword: "newPassword123",
+ };
+
+ it("should change password successfully", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ password: "$2b$10$mockHashedPassword",
+ };
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(mockUser),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+ userRepository.save.mockResolvedValue(mockUser);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(true);
+
+ const result = await service.changePassword(1, changePasswordDto);
+
+ expect(result).toEqual({ message: "Password changed successfully" });
+ expect(userRepository.save).toHaveBeenCalled();
+ });
+
+ it("should throw NotFoundException if user not found", async () => {
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(null),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+
+ await expect(
+ service.changePassword(999, changePasswordDto),
+ ).rejects.toThrow(new NotFoundException("User not found"));
+ });
+
+ it("should throw BadRequestException if current password is incorrect", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ password: "$2b$10$mockHashedPassword",
+ };
+ const queryBuilder = {
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getOne: jest.fn().mockResolvedValue(mockUser),
+ };
+ userRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(false);
+
+ await expect(
+ service.changePassword(1, changePasswordDto),
+ ).rejects.toThrow(
+ new BadRequestException("Current password is incorrect"),
+ );
+ });
+ });
+
+ describe("forgotPassword", () => {
+ it("should generate reset token for valid email", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ passwordResetToken: null,
+ passwordResetExpires: null,
+ };
+ userRepository.findOne.mockResolvedValue(mockUser);
+ userRepository.save.mockResolvedValue(mockUser);
+
+ const result = await service.forgotPassword("test@example.com");
+
+ expect(result.message).toEqual(
+ "If this email is registered, you will receive a password reset link",
+ );
+ expect(userRepository.save).toHaveBeenCalled();
+ });
+
+ it("should return success message even if email not found", async () => {
+ userRepository.findOne.mockResolvedValue(null);
+
+ const result = await service.forgotPassword("nonexistent@example.com");
+
+ expect(result.message).toEqual(
+ "If this email is registered, you will receive a password reset link",
+ );
+ expect(userRepository.save).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("validateUser", () => {
+ it("should validate and return user", async () => {
+ const mockUser = {
+ id: 1,
+ email: "test@example.com",
+ role: UserRole.USER,
+ };
+ const payload: JwtPayload = {
+ sub: 1,
+ email: "test@example.com",
+ role: UserRole.USER,
+ };
+ userRepository.findOne.mockResolvedValue(mockUser);
+
+ const result = await service.validateUser(payload);
+
+ expect(result).toEqual(mockUser);
+ expect(userRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
+ });
+
+ it("should return null if user not found", async () => {
+ const payload: JwtPayload = {
+ sub: 999,
+ email: "test@example.com",
+ role: UserRole.USER,
+ };
+ userRepository.findOne.mockResolvedValue(null);
+
+ const result = await service.validateUser(payload);
+
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts
new file mode 100644
index 0000000..052cbe7
--- /dev/null
+++ b/server/src/auth/auth.service.ts
@@ -0,0 +1,246 @@
+import {
+ Injectable,
+ UnauthorizedException,
+ ConflictException,
+ BadRequestException,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { JwtService } from '@nestjs/jwt';
+import { ConfigService } from '@nestjs/config';
+import * as bcrypt from 'bcrypt';
+import * as crypto from 'crypto';
+import { User, UserRole } from '../entities/user.entity';
+import {
+ RegisterDto,
+ LoginDto,
+ UpdateProfileDto,
+ ChangePasswordDto,
+ AuthResponseDto,
+} from './dto/auth.dto';
+
+export interface JwtPayload {
+ sub: number;
+ email: string;
+ role: UserRole;
+}
+
+@Injectable()
+export class AuthService {
+ constructor(
+ @InjectRepository(User)
+ private readonly userRepository: Repository,
+ private readonly jwtService: JwtService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ async register(registerDto: RegisterDto): Promise {
+ const { email, username, password, displayName, role } = registerDto;
+
+ // Check if user already exists
+ const existingUser = await this.userRepository.findOne({
+ where: [{ email }, { username }],
+ });
+
+ if (existingUser) {
+ if (existingUser.email === email) {
+ throw new ConflictException('Email already registered');
+ }
+ throw new ConflictException('Username already taken');
+ }
+
+ // Create new user
+ const user = this.userRepository.create({
+ email,
+ username,
+ password,
+ displayName: displayName || username,
+ role: role || UserRole.USER,
+ emailVerificationToken: crypto.randomBytes(32).toString('hex'),
+ });
+
+ await this.userRepository.save(user);
+
+ // Generate JWT token
+ const token = this.generateToken(user);
+
+ return {
+ message: 'Registration successful',
+ token,
+ user: {
+ id: user.id,
+ email: user.email,
+ username: user.username,
+ role: user.role,
+ displayName: user.displayName,
+ avatarUrl: user.avatarUrl,
+ },
+ };
+ }
+
+ async login(loginDto: LoginDto): Promise {
+ const { email, password } = loginDto;
+
+ // Find user with password (password is excluded by default)
+ const user = await this.userRepository
+ .createQueryBuilder('user')
+ .addSelect('user.password')
+ .where('user.email = :email', { email })
+ .getOne();
+
+ if (!user) {
+ throw new UnauthorizedException('Invalid email or password');
+ }
+
+ if (!user.isActive) {
+ throw new UnauthorizedException('Account is deactivated');
+ }
+
+ // Verify password
+ const isPasswordValid = await bcrypt.compare(password, user.password);
+ if (!isPasswordValid) {
+ throw new UnauthorizedException('Invalid email or password');
+ }
+
+ // Update last login
+ user.lastLoginAt = new Date();
+ await this.userRepository.save(user);
+
+ // Generate JWT token
+ const token = this.generateToken(user);
+
+ return {
+ message: 'Login successful',
+ token,
+ user: {
+ id: user.id,
+ email: user.email,
+ username: user.username,
+ role: user.role,
+ displayName: user.displayName,
+ avatarUrl: user.avatarUrl,
+ },
+ };
+ }
+
+ async getProfile(userId: number): Promise {
+ const user = await this.userRepository.findOne({
+ where: { id: userId },
+ relations: ['artisan'],
+ });
+
+ if (!user) {
+ throw new NotFoundException('User not found');
+ }
+
+ return user;
+ }
+
+ async updateProfile(userId: number, updateDto: UpdateProfileDto): Promise {
+ const user = await this.userRepository.findOne({ where: { id: userId } });
+
+ if (!user) {
+ throw new NotFoundException('User not found');
+ }
+
+ Object.assign(user, updateDto);
+ await this.userRepository.save(user);
+
+ return user;
+ }
+
+ async changePassword(userId: number, changePasswordDto: ChangePasswordDto): Promise<{ message: string }> {
+ const { currentPassword, newPassword } = changePasswordDto;
+
+ // Get user with password
+ const user = await this.userRepository
+ .createQueryBuilder('user')
+ .addSelect('user.password')
+ .where('user.id = :id', { id: userId })
+ .getOne();
+
+ if (!user) {
+ throw new NotFoundException('User not found');
+ }
+
+ // Verify current password
+ const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
+ if (!isPasswordValid) {
+ throw new BadRequestException('Current password is incorrect');
+ }
+
+ // Update password
+ user.password = newPassword;
+ await this.userRepository.save(user);
+
+ return { message: 'Password changed successfully' };
+ }
+
+ async forgotPassword(email: string): Promise<{ message: string }> {
+ const user = await this.userRepository.findOne({ where: { email } });
+
+ if (!user) {
+ // Don't reveal if email exists or not
+ return { message: 'If this email is registered, you will receive a password reset link' };
+ }
+
+ // Generate reset token
+ const resetToken = crypto.randomBytes(32).toString('hex');
+ user.passwordResetToken = await bcrypt.hash(resetToken, 10);
+ user.passwordResetExpires = new Date(Date.now() + 3600000); // 1 hour
+
+ await this.userRepository.save(user);
+
+ // TODO: Send email with reset link
+ // await this.emailService.sendPasswordResetEmail(user.email, resetToken);
+
+ return { message: 'If this email is registered, you will receive a password reset link' };
+ }
+
+ async resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
+ // Find user with valid reset token
+ const users = await this.userRepository.find({
+ where: { passwordResetExpires: new Date() },
+ });
+
+ let validUser: User | null = null;
+ for (const user of users) {
+ if (user.passwordResetToken) {
+ const isValid = await bcrypt.compare(token, user.passwordResetToken);
+ if (isValid && user.passwordResetExpires > new Date()) {
+ validUser = user;
+ break;
+ }
+ }
+ }
+
+ if (!validUser) {
+ throw new BadRequestException('Invalid or expired reset token');
+ }
+
+ // Update password and clear reset token
+ validUser.password = newPassword;
+ validUser.passwordResetToken = null;
+ validUser.passwordResetExpires = null;
+
+ await this.userRepository.save(validUser);
+
+ return { message: 'Password reset successful' };
+ }
+
+ async validateUser(payload: JwtPayload): Promise {
+ return this.userRepository.findOne({ where: { id: payload.sub } });
+ }
+
+ private generateToken(user: User): string {
+ const payload: JwtPayload = {
+ sub: user.id,
+ email: user.email,
+ role: user.role,
+ };
+
+ return this.jwtService.sign(payload);
+ }
+}
+
diff --git a/server/src/auth/decorators/current-user.decorator.ts b/server/src/auth/decorators/current-user.decorator.ts
new file mode 100644
index 0000000..44696b5
--- /dev/null
+++ b/server/src/auth/decorators/current-user.decorator.ts
@@ -0,0 +1,16 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+import { User } from '../../entities/user.entity';
+
+export const CurrentUser = createParamDecorator(
+ (data: keyof User | undefined, ctx: ExecutionContext) => {
+ const request = ctx.switchToHttp().getRequest();
+ const user = request.user as User;
+
+ if (data) {
+ return user?.[data];
+ }
+
+ return user;
+ },
+);
+
diff --git a/server/src/auth/decorators/public.decorator.ts b/server/src/auth/decorators/public.decorator.ts
new file mode 100644
index 0000000..052aaa7
--- /dev/null
+++ b/server/src/auth/decorators/public.decorator.ts
@@ -0,0 +1,5 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const IS_PUBLIC_KEY = 'isPublic';
+export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+
diff --git a/server/src/auth/decorators/roles.decorator.ts b/server/src/auth/decorators/roles.decorator.ts
new file mode 100644
index 0000000..8abe7aa
--- /dev/null
+++ b/server/src/auth/decorators/roles.decorator.ts
@@ -0,0 +1,6 @@
+import { SetMetadata } from '@nestjs/common';
+import { UserRole } from '../../entities/user.entity';
+
+export const ROLES_KEY = 'roles';
+export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+
diff --git a/server/src/auth/dto/auth.dto.ts b/server/src/auth/dto/auth.dto.ts
new file mode 100644
index 0000000..d0ad154
--- /dev/null
+++ b/server/src/auth/dto/auth.dto.ts
@@ -0,0 +1,94 @@
+import { IsEmail, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
+import { UserRole } from '../../entities/user.entity';
+
+export class RegisterDto {
+ @IsEmail()
+ email: string;
+
+ @IsString()
+ @MinLength(3)
+ username: string;
+
+ @IsString()
+ @MinLength(8)
+ password: string;
+
+ @IsOptional()
+ @IsString()
+ displayName?: string;
+
+ @IsOptional()
+ @IsEnum(UserRole)
+ role?: UserRole;
+}
+
+export class LoginDto {
+ @IsEmail()
+ email: string;
+
+ @IsString()
+ password: string;
+}
+
+export class UpdateProfileDto {
+ @IsOptional()
+ @IsString()
+ displayName?: string;
+
+ @IsOptional()
+ @IsString()
+ avatarUrl?: string;
+
+ @IsOptional()
+ @IsString()
+ phone?: string;
+
+ @IsOptional()
+ profile?: {
+ bio?: string;
+ location?: string;
+ website?: string;
+ socialLinks?: {
+ instagram?: string;
+ facebook?: string;
+ twitter?: string;
+ };
+ };
+}
+
+export class ChangePasswordDto {
+ @IsString()
+ currentPassword: string;
+
+ @IsString()
+ @MinLength(8)
+ newPassword: string;
+}
+
+export class ForgotPasswordDto {
+ @IsEmail()
+ email: string;
+}
+
+export class ResetPasswordDto {
+ @IsString()
+ token: string;
+
+ @IsString()
+ @MinLength(8)
+ newPassword: string;
+}
+
+export class AuthResponseDto {
+ message: string;
+ token: string;
+ user: {
+ id: number;
+ email: string;
+ username: string;
+ role: UserRole;
+ displayName?: string;
+ avatarUrl?: string;
+ };
+}
+
diff --git a/server/src/auth/guards/jwt-auth.guard.ts b/server/src/auth/guards/jwt-auth.guard.ts
new file mode 100644
index 0000000..6485135
--- /dev/null
+++ b/server/src/auth/guards/jwt-auth.guard.ts
@@ -0,0 +1,26 @@
+import { Injectable, ExecutionContext } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+import { Reflector } from '@nestjs/core';
+import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {
+ constructor(private reflector: Reflector) {
+ super();
+ }
+
+ canActivate(context: ExecutionContext) {
+ // Check if route is marked as public
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+
+ if (isPublic) {
+ return true;
+ }
+
+ return super.canActivate(context);
+ }
+}
+
diff --git a/server/src/auth/guards/roles.guard.ts b/server/src/auth/guards/roles.guard.ts
new file mode 100644
index 0000000..d3c1d63
--- /dev/null
+++ b/server/src/auth/guards/roles.guard.ts
@@ -0,0 +1,24 @@
+import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { UserRole } from '../../entities/user.entity';
+import { ROLES_KEY } from '../decorators/roles.decorator';
+
+@Injectable()
+export class RolesGuard implements CanActivate {
+ constructor(private reflector: Reflector) {}
+
+ canActivate(context: ExecutionContext): boolean {
+ const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+
+ if (!requiredRoles) {
+ return true;
+ }
+
+ const { user } = context.switchToHttp().getRequest();
+ return requiredRoles.some((role) => user?.role === role);
+ }
+}
+
diff --git a/server/src/auth/strategies/jwt.strategy.ts b/server/src/auth/strategies/jwt.strategy.ts
new file mode 100644
index 0000000..3e07016
--- /dev/null
+++ b/server/src/auth/strategies/jwt.strategy.ts
@@ -0,0 +1,34 @@
+import { Injectable, UnauthorizedException } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+import { ConfigService } from '@nestjs/config';
+import { AuthService, JwtPayload } from '../auth.service';
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly authService: AuthService,
+ ) {
+ super({
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ ignoreExpiration: false,
+ secretOrKey: configService.get('JWT_SECRET', 'craftshk-secret-key-change-in-production'),
+ });
+ }
+
+ async validate(payload: JwtPayload) {
+ const user = await this.authService.validateUser(payload);
+
+ if (!user) {
+ throw new UnauthorizedException('User not found');
+ }
+
+ if (!user.isActive) {
+ throw new UnauthorizedException('Account is deactivated');
+ }
+
+ return user;
+ }
+}
+
diff --git a/server/src/config/gemini.config.ts b/server/src/config/gemini.config.ts
index bc23d1e..7e2c886 100644
--- a/server/src/config/gemini.config.ts
+++ b/server/src/config/gemini.config.ts
@@ -1,7 +1,11 @@
+import { Logger } from '@nestjs/common';
+
+const logger = new Logger('GeminiConfig');
+
export const getGeminiApiKey = (): string | undefined => {
const value = process.env.GEMINI_API_KEY;
if (!value) {
- console.warn('GEMINI_API_KEY environment variable not set. AI features will fail.');
+ logger.warn('GEMINI_API_KEY environment variable not set. AI features will fail.');
}
return value;
};
diff --git a/server/src/database/database.module.ts b/server/src/database/database.module.ts
index b963766..2379735 100644
--- a/server/src/database/database.module.ts
+++ b/server/src/database/database.module.ts
@@ -7,6 +7,7 @@ import { Event } from '../entities/event.entity';
import { Artisan } from '../entities/artisan.entity';
import { Order } from '../entities/order.entity';
import { MessageThread } from '../entities/message-thread.entity';
+import { User } from '../entities/user.entity';
import * as path from 'path';
const envFilePath = [
@@ -25,16 +26,54 @@ const envFilePath = [
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
- useFactory: (configService: ConfigService) => ({
- type: 'sqlite',
- database: configService.get('DATABASE_PATH', 'database.sqlite'),
- entities: [Craft, Product, Event, Artisan, Order, MessageThread],
- synchronize: true, // Auto-create tables (safe for SQLite)
- logging: configService.get('NODE_ENV') === 'development',
- }),
+ useFactory: (configService: ConfigService) => {
+ const dbType = configService.get('DATABASE_TYPE', 'sqlite');
+ const isProduction = configService.get('NODE_ENV') === 'production';
+
+ // All entities including User
+ const entities = [Craft, Product, Event, Artisan, Order, MessageThread, User];
+
+ // PostgreSQL configuration for production
+ if (dbType === 'postgres') {
+ return {
+ type: 'postgres',
+ host: configService.get('DATABASE_HOST', 'localhost'),
+ port: configService.get('DATABASE_PORT', 5432),
+ username: configService.get('DATABASE_USER', 'postgres'),
+ password: configService.get('DATABASE_PASSWORD', ''),
+ database: configService.get('DATABASE_NAME', 'craftshk'),
+ entities,
+ // Use migrations in production, synchronize only in development
+ synchronize: !isProduction,
+ migrationsRun: isProduction,
+ migrations: [path.join(__dirname, '..', 'migrations', '*.{ts,js}')],
+ logging: configService.get('NODE_ENV') === 'development',
+ ssl: configService.get('DATABASE_SSL', false)
+ ? { rejectUnauthorized: false }
+ : false,
+ // Connection pool settings for production
+ extra: isProduction
+ ? {
+ max: 20,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 2000,
+ }
+ : undefined,
+ };
+ }
+
+ // SQLite configuration for local development
+ return {
+ type: 'sqlite',
+ database: configService.get('DATABASE_PATH', 'database.sqlite'),
+ entities,
+ synchronize: true, // Auto-create tables (safe for SQLite in development)
+ logging: configService.get('NODE_ENV') === 'development',
+ };
+ },
inject: [ConfigService],
}),
],
exports: [TypeOrmModule],
})
-export class DatabaseModule {}
\ No newline at end of file
+export class DatabaseModule {}
diff --git a/server/src/entities/message-thread.entity.ts b/server/src/entities/message-thread.entity.ts
index 33d37f1..ca5e9e0 100644
--- a/server/src/entities/message-thread.entity.ts
+++ b/server/src/entities/message-thread.entity.ts
@@ -1,6 +1,6 @@
-import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
+import { Entity, Column } from "typeorm";
-@Entity('message_threads')
+@Entity("message_threads")
export class MessageThread {
@Column({ primary: true })
id: string;
diff --git a/server/src/entities/order.entity.ts b/server/src/entities/order.entity.ts
index e0086bd..9e8cfd7 100644
--- a/server/src/entities/order.entity.ts
+++ b/server/src/entities/order.entity.ts
@@ -1,7 +1,7 @@
-import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm';
-import { Product } from './product.entity';
+import { Entity, Column, ManyToOne, JoinColumn } from "typeorm";
+import { Product } from "./product.entity";
-@Entity('orders')
+@Entity("orders")
export class Order {
@Column({ primary: true })
id: string;
@@ -10,7 +10,7 @@ export class Order {
customerName: string;
@ManyToOne(() => Product)
- @JoinColumn({ name: 'productId' })
+ @JoinColumn({ name: "productId" })
product: Product;
@Column()
@@ -19,10 +19,10 @@ export class Order {
@Column()
quantity: number;
- @Column('decimal', { precision: 10, scale: 2 })
+ @Column("decimal", { precision: 10, scale: 2 })
total: number;
- @Column({ type: 'date' })
+ @Column({ type: "date" })
date: string;
@Column()
diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts
new file mode 100644
index 0000000..e844c0c
--- /dev/null
+++ b/server/src/entities/user.entity.ts
@@ -0,0 +1,116 @@
+import {
+ Entity,
+ Column,
+ PrimaryGeneratedColumn,
+ CreateDateColumn,
+ UpdateDateColumn,
+ BeforeInsert,
+ BeforeUpdate,
+ OneToOne,
+ JoinColumn,
+} from "typeorm";
+import * as bcrypt from "bcrypt";
+import { Artisan } from "./artisan.entity";
+
+export enum UserRole {
+ USER = "user",
+ ARTISAN = "artisan",
+ ADMIN = "admin",
+}
+
+@Entity("users")
+export class User {
+ @PrimaryGeneratedColumn()
+ id: number;
+
+ @Column({ unique: true })
+ email: string;
+
+ @Column({ unique: true })
+ username: string;
+
+ @Column({ select: false }) // Password won't be selected by default
+ password: string;
+
+ @Column({
+ type: "varchar",
+ default: UserRole.USER,
+ })
+ role: UserRole;
+
+ @Column({ nullable: true })
+ displayName: string;
+
+ @Column({ nullable: true })
+ avatarUrl: string;
+
+ @Column({ nullable: true })
+ phone: string;
+
+ @Column({ type: "json", nullable: true })
+ profile: {
+ bio?: string;
+ location?: string;
+ website?: string;
+ socialLinks?: {
+ instagram?: string;
+ facebook?: string;
+ twitter?: string;
+ };
+ };
+
+ @Column({ default: true })
+ isActive: boolean;
+
+ @Column({ default: false })
+ emailVerified: boolean;
+
+ @Column({ nullable: true })
+ emailVerificationToken: string;
+
+ @Column({ nullable: true })
+ passwordResetToken: string;
+
+ @Column({ nullable: true })
+ passwordResetExpires: Date;
+
+ @Column({ nullable: true })
+ lastLoginAt: Date;
+
+ @CreateDateColumn()
+ createdAt: Date;
+
+ @UpdateDateColumn()
+ updatedAt: Date;
+
+ // If user is an artisan, link to artisan profile
+ @OneToOne(() => Artisan, { nullable: true })
+ @JoinColumn()
+ artisan: Artisan;
+
+ @Column({ nullable: true })
+ artisanId: number;
+
+ // Hash password before saving
+ @BeforeInsert()
+ @BeforeUpdate()
+ async hashPassword() {
+ if (this.password && !this.password.startsWith("$2b$")) {
+ const salt = await bcrypt.genSalt(10);
+ this.password = await bcrypt.hash(this.password, salt);
+ }
+ }
+
+ // Compare password for login
+ async comparePassword(candidatePassword: string): Promise {
+ return bcrypt.compare(candidatePassword, this.password);
+ }
+
+ // Sanitize user object for API responses
+ toJSON() {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { password, emailVerificationToken, passwordResetToken, ...user } =
+ this as any;
+ return user;
+ }
+}
diff --git a/server/src/events/events.service.spec.ts b/server/src/events/events.service.spec.ts
new file mode 100644
index 0000000..0124502
--- /dev/null
+++ b/server/src/events/events.service.spec.ts
@@ -0,0 +1,211 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { EventsService } from './events.service';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { NotFoundException } from '@nestjs/common';
+import { Event } from '../entities/event.entity';
+import { Repository } from 'typeorm';
+
+describe('EventsService', () => {
+ let service: EventsService;
+ let eventRepository: MockType>;
+
+ type MockType = {
+ [P in keyof T]?: jest.Mock;
+ };
+
+ const mockRepositoryFactory: () => MockType> = jest.fn(() => ({
+ find: jest.fn(),
+ findOne: jest.fn(),
+ create: jest.fn(),
+ save: jest.fn(),
+ remove: jest.fn(),
+ }));
+
+ const mockDate = new Date('2024-12-28');
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ EventsService,
+ {
+ provide: getRepositoryToken(Event),
+ useFactory: mockRepositoryFactory,
+ },
+ ],
+ }).compile();
+
+ service = module.get(EventsService);
+ eventRepository = module.get(getRepositoryToken(Event));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should return an array of events', async () => {
+ const mockEvents = [
+ {
+ id: 1,
+ title: 'Craft Workshop',
+ description: 'Learn traditional crafts',
+ imageUrl: 'http://example.com/event1.jpg',
+ date: mockDate,
+ location: 'Central, Hong Kong',
+ maxAttendees: 20,
+ currentAttendees: 15,
+ isActive: true,
+ createdAt: mockDate,
+ updatedAt: mockDate,
+ },
+ {
+ id: 2,
+ title: 'Art Exhibition',
+ description: 'Display of local crafts',
+ imageUrl: 'http://example.com/event2.jpg',
+ date: new Date('2024-12-30'),
+ location: 'Kowloon, Hong Kong',
+ maxAttendees: 50,
+ currentAttendees: 30,
+ isActive: true,
+ createdAt: mockDate,
+ updatedAt: mockDate,
+ },
+ ] as any[];
+
+ eventRepository.find.mockResolvedValue(mockEvents);
+
+ const result = await service.findAll();
+
+ expect(result).toEqual(mockEvents);
+ expect(result).toHaveLength(2);
+ expect(result[0].title).toBe('Craft Workshop');
+ expect(result[1].title).toBe('Art Exhibition');
+ expect(eventRepository.find).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return an empty array when no events exist', async () => {
+ eventRepository.find.mockResolvedValue([]);
+
+ const result = await service.findAll();
+
+ expect(result).toEqual([]);
+ expect(result).toHaveLength(0);
+ expect(eventRepository.find).toHaveBeenCalledTimes(1);
+ });
+
+ it('should include all event properties', async () => {
+ const mockEvent = {
+ id: 1,
+ title: 'Complete Event',
+ description: 'Event with all fields',
+ imageUrl: 'http://example.com/event.jpg',
+ date: mockDate,
+ location: 'Hong Kong',
+ maxAttendees: 30,
+ currentAttendees: 10,
+ isActive: true,
+ createdAt: mockDate,
+ updatedAt: mockDate,
+ } as any;
+
+ eventRepository.find.mockResolvedValue([mockEvent]);
+
+ const result = await service.findAll();
+
+ expect(result[0]).toHaveProperty('id');
+ expect(result[0]).toHaveProperty('title');
+ expect(result[0]).toHaveProperty('description');
+ expect(result[0]).toHaveProperty('date');
+ expect(result[0]).toHaveProperty('location');
+ expect(result[0]).toHaveProperty('maxAttendees');
+ expect(result[0]).toHaveProperty('currentAttendees');
+ });
+ });
+
+ describe('findOne', () => {
+ const mockEvent = {
+ id: 1,
+ title: 'Test Event',
+ description: 'Test Description',
+ imageUrl: 'http://example.com/event.jpg',
+ date: mockDate,
+ location: 'Test Location',
+ maxAttendees: 25,
+ currentAttendees: 10,
+ isActive: true,
+ createdAt: mockDate,
+ updatedAt: mockDate,
+ } as any;
+
+ it('should return an event when found', async () => {
+ eventRepository.findOne.mockResolvedValue(mockEvent);
+
+ const result = await service.findOne(1);
+
+ expect(result).toEqual(mockEvent);
+ expect(result.id).toBe(1);
+ expect(result.title).toBe('Test Event');
+ expect(eventRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
+ });
+
+ it('should throw NotFoundException when event not found', async () => {
+ eventRepository.findOne.mockResolvedValue(null);
+
+ await expect(service.findOne(999)).rejects.toThrow(
+ new NotFoundException('Event with ID 999 not found'),
+ );
+
+ expect(eventRepository.findOne).toHaveBeenCalledWith({ where: { id: 999 } });
+ });
+
+ it('should call repository with correct parameters', async () => {
+ eventRepository.findOne.mockResolvedValue(mockEvent);
+
+ await service.findOne(5);
+
+ expect(eventRepository.findOne).toHaveBeenCalledWith({ where: { id: 5 } });
+ });
+
+ it('should return event with all properties intact', async () => {
+ const completeEvent = {
+ id: 3,
+ title: 'Complete Test Event',
+ description: 'Full event data',
+ imageUrl: 'http://example.com/complete.jpg',
+ date: new Date('2025-01-15'),
+ location: 'Causeway Bay',
+ maxAttendees: 40,
+ currentAttendees: 25,
+ isActive: true,
+ createdAt: mockDate,
+ updatedAt: mockDate,
+ } as any;
+
+ eventRepository.findOne.mockResolvedValue(completeEvent);
+
+ const result = await service.findOne(3);
+
+ expect(result).toEqual(completeEvent);
+ expect(result.id).toBe(3);
+ expect(result.title).toBe('Complete Test Event');
+ expect(result.location).toBe('Causeway Bay');
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle repository errors gracefully in findAll', async () => {
+ const error = new Error('Database connection failed');
+ eventRepository.find.mockRejectedValue(error);
+
+ await expect(service.findAll()).rejects.toThrow('Database connection failed');
+ });
+
+ it('should handle repository errors gracefully in findOne', async () => {
+ const error = new Error('Database query failed');
+ eventRepository.findOne.mockRejectedValue(error);
+
+ await expect(service.findOne(1)).rejects.toThrow('Database query failed');
+ });
+ });
+});
diff --git a/server/src/health/health.controller.ts b/server/src/health/health.controller.ts
new file mode 100644
index 0000000..276c19e
--- /dev/null
+++ b/server/src/health/health.controller.ts
@@ -0,0 +1,44 @@
+import { Controller, Get } from '@nestjs/common';
+import { HealthCheckService, HealthCheck, TypeOrmHealthIndicator, MemoryHealthIndicator } from '@nestjs/terminus';
+import { SkipThrottle } from '@nestjs/throttler';
+
+@Controller('health')
+@SkipThrottle() // Health checks should not be rate limited
+export class HealthController {
+ constructor(
+ private health: HealthCheckService,
+ private db: TypeOrmHealthIndicator,
+ private memory: MemoryHealthIndicator,
+ ) {}
+
+ @Get()
+ @HealthCheck()
+ check() {
+ return this.health.check([
+ // Check database connection
+ () => this.db.pingCheck('database'),
+ // Check memory heap (should be less than 300MB)
+ () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
+ // Check RSS memory (should be less than 300MB)
+ () => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
+ ]);
+ }
+
+ @Get('ready')
+ @HealthCheck()
+ ready() {
+ // Readiness probe: check if the app is ready to receive traffic
+ return this.health.check([
+ () => this.db.pingCheck('database'),
+ ]);
+ }
+
+ @Get('live')
+ @HealthCheck()
+ live() {
+ // Liveness probe: check if the app is alive (simple check)
+ return this.health.check([
+ () => Promise.resolve({ live: { status: 'up' } }),
+ ]);
+ }
+}
diff --git a/server/src/health/health.module.ts b/server/src/health/health.module.ts
new file mode 100644
index 0000000..0208ef7
--- /dev/null
+++ b/server/src/health/health.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { TerminusModule } from '@nestjs/terminus';
+import { HealthController } from './health.controller';
+
+@Module({
+ imports: [TerminusModule],
+ controllers: [HealthController],
+})
+export class HealthModule {}
diff --git a/server/src/logger/logger.module.ts b/server/src/logger/logger.module.ts
new file mode 100644
index 0000000..cb16f85
--- /dev/null
+++ b/server/src/logger/logger.module.ts
@@ -0,0 +1,66 @@
+import { Module } from '@nestjs/common';
+import { WinstonModule } from 'nest-winston';
+import * as winston from 'winston';
+
+const logFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.errors({ stack: true }),
+ winston.format.splat(),
+ winston.format.json(),
+);
+
+const consoleFormat = winston.format.combine(
+ winston.format.colorize(),
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.printf(({ timestamp, level, message, context, ...meta }) => {
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
+ const contextStr = context ? `[${context}]` : '';
+ return `${timestamp} ${level} ${contextStr} ${message} ${metaStr}`;
+ }),
+);
+
+@Module({
+ imports: [
+ WinstonModule.forRoot({
+ level: process.env.LOG_LEVEL || 'info',
+ transports: [
+ // Console transport for all environments
+ new winston.transports.Console({
+ format: process.env.NODE_ENV === 'production' ? logFormat : consoleFormat,
+ }),
+ // File transport for errors in production
+ ...(process.env.NODE_ENV === 'production'
+ ? [
+ new winston.transports.File({
+ filename: 'logs/error.log',
+ level: 'error',
+ format: logFormat,
+ maxsize: 5242880, // 5MB
+ maxFiles: 5,
+ }),
+ new winston.transports.File({
+ filename: 'logs/combined.log',
+ format: logFormat,
+ maxsize: 5242880, // 5MB
+ maxFiles: 5,
+ }),
+ ]
+ : []),
+ ],
+ exceptionHandlers: [
+ new winston.transports.File({
+ filename: 'logs/exceptions.log',
+ format: logFormat,
+ }),
+ ],
+ rejectionHandlers: [
+ new winston.transports.File({
+ filename: 'logs/rejections.log',
+ format: logFormat,
+ }),
+ ],
+ }),
+ ],
+ exports: [WinstonModule],
+})
+export class LoggerModule {}
diff --git a/server/src/main.ts b/server/src/main.ts
index 633e7b3..68e4b66 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -1,16 +1,28 @@
import { NestFactory } from '@nestjs/core';
-import { ValidationPipe } from '@nestjs/common';
+import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import * as express from 'express';
+import helmet from 'helmet';
+import * as Sentry from '@sentry/node';
+
+// Initialize Sentry if DSN is provided
+if (process.env.SENTRY_DSN) {
+ Sentry.init({
+ dsn: process.env.SENTRY_DSN,
+ environment: process.env.NODE_ENV || 'development',
+ tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
+ });
+}
// For Vercel: export a handler creator
export async function createNestServer() {
- const app = await NestFactory.create(AppModule, {
+ const app = await NestFactory.create(AppModule, {
bodyParser: false,
- cors: true // Enable CORS at creation
+ cors: true, // Enable CORS at creation
+ logger: ['error', 'warn', 'log', 'debug', 'verbose']
});
// CORS must be configured FIRST before any middleware
@@ -47,7 +59,7 @@ export async function createNestServer() {
if (isAllowed) {
callback(null, true);
} else {
- console.warn(`⚠️ CORS request from unauthorized origin: ${origin}`);
+ Logger.warn(`⚠️ CORS request from unauthorized origin: ${origin}`, 'CORS');
callback(null, true); // Still allow for development; set to false in strict production
}
},
@@ -58,6 +70,12 @@ export async function createNestServer() {
optionsSuccessStatus: 204,
});
+ // Security headers with Helmet.js
+ app.use(helmet({
+ contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
+ crossOriginEmbedderPolicy: false,
+ }));
+
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
@@ -84,8 +102,10 @@ export async function createNestServer() {
// For local dev: keep original bootstrap
if (require.main === module) {
(async () => {
+ const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
- cors: true // Enable CORS at creation
+ cors: true, // Enable CORS at creation
+ logger: ['error', 'warn', 'log', 'debug', 'verbose']
});
// CORS configuration MUST be set FIRST before any middleware
@@ -127,7 +147,7 @@ if (require.main === module) {
if (isAllowed) {
callback(null, true);
} else {
- console.warn(`🚫 Blocked CORS request from origin: ${origin}`);
+ Logger.warn(`🚫 Blocked CORS request from origin: ${origin}`, 'CORS');
callback(null, true); // Still allow for development; set to false in production
}
},
@@ -138,6 +158,12 @@ if (require.main === module) {
optionsSuccessStatus: 204,
});
+ // Security headers with Helmet.js
+ app.use(helmet({
+ contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
+ crossOriginEmbedderPolicy: false,
+ }));
+
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.useStaticAssets(join(__dirname, '..', '..', 'public'), {
@@ -155,9 +181,9 @@ if (require.main === module) {
}));
const port = process.env.PORT || 3001;
const host = process.env.HOST || '0.0.0.0';
- console.log(`Attempting to start server on http://${host}:${port}`);
+ logger.log(`Attempting to start server on http://${host}:${port}`);
await app.listen(port, host);
- console.log(`🚀 Backend server is running on: http://${host}:${port}`);
- console.log(`📋 CORS origins:`, allowedOrigins);
+ logger.log(`🚀 Backend server is running on: http://${host}:${port}`);
+ logger.log(`📋 CORS origins: ${JSON.stringify(allowedOrigins)}`);
})();
}
diff --git a/server/src/migrations/1703800000000-CreateUserTable.ts b/server/src/migrations/1703800000000-CreateUserTable.ts
new file mode 100644
index 0000000..586faff
--- /dev/null
+++ b/server/src/migrations/1703800000000-CreateUserTable.ts
@@ -0,0 +1,164 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm';
+
+export class CreateUserTable1703800000000 implements MigrationInterface {
+ name = 'CreateUserTable1703800000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ name: 'users',
+ columns: [
+ {
+ name: 'id',
+ type: 'int',
+ isPrimary: true,
+ isGenerated: true,
+ generationStrategy: 'increment',
+ },
+ {
+ name: 'email',
+ type: 'varchar',
+ isUnique: true,
+ },
+ {
+ name: 'username',
+ type: 'varchar',
+ isUnique: true,
+ },
+ {
+ name: 'password',
+ type: 'varchar',
+ },
+ {
+ name: 'role',
+ type: 'varchar',
+ default: "'user'",
+ },
+ {
+ name: 'displayName',
+ type: 'varchar',
+ isNullable: true,
+ },
+ {
+ name: 'avatarUrl',
+ type: 'varchar',
+ isNullable: true,
+ },
+ {
+ name: 'phone',
+ type: 'varchar',
+ isNullable: true,
+ },
+ {
+ name: 'profile',
+ type: 'json',
+ isNullable: true,
+ },
+ {
+ name: 'isActive',
+ type: 'boolean',
+ default: true,
+ },
+ {
+ name: 'emailVerified',
+ type: 'boolean',
+ default: false,
+ },
+ {
+ name: 'emailVerificationToken',
+ type: 'varchar',
+ isNullable: true,
+ },
+ {
+ name: 'passwordResetToken',
+ type: 'varchar',
+ isNullable: true,
+ },
+ {
+ name: 'passwordResetExpires',
+ type: 'timestamp',
+ isNullable: true,
+ },
+ {
+ name: 'lastLoginAt',
+ type: 'timestamp',
+ isNullable: true,
+ },
+ {
+ name: 'artisanId',
+ type: 'int',
+ isNullable: true,
+ },
+ {
+ name: 'createdAt',
+ type: 'timestamp',
+ default: 'CURRENT_TIMESTAMP',
+ },
+ {
+ name: 'updatedAt',
+ type: 'timestamp',
+ default: 'CURRENT_TIMESTAMP',
+ },
+ ],
+ }),
+ true,
+ );
+
+ // Create indexes
+ await queryRunner.createIndex(
+ 'users',
+ new TableIndex({
+ name: 'IDX_USERS_EMAIL',
+ columnNames: ['email'],
+ }),
+ );
+
+ await queryRunner.createIndex(
+ 'users',
+ new TableIndex({
+ name: 'IDX_USERS_USERNAME',
+ columnNames: ['username'],
+ }),
+ );
+
+ await queryRunner.createIndex(
+ 'users',
+ new TableIndex({
+ name: 'IDX_USERS_ROLE',
+ columnNames: ['role'],
+ }),
+ );
+
+ // Add foreign key to artisans table
+ await queryRunner.createForeignKey(
+ 'users',
+ new TableForeignKey({
+ columnNames: ['artisanId'],
+ referencedColumnNames: ['id'],
+ referencedTableName: 'artisans',
+ onDelete: 'SET NULL',
+ }),
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const table = await queryRunner.getTable('users');
+
+ // Drop foreign key
+ const foreignKey = table?.foreignKeys.find(
+ (fk) => fk.columnNames.indexOf('artisanId') !== -1,
+ );
+ if (foreignKey) {
+ await queryRunner.dropForeignKey('users', foreignKey);
+ }
+
+ // Drop indexes
+ await queryRunner.dropIndex('users', 'IDX_USERS_EMAIL');
+ await queryRunner.dropIndex('users', 'IDX_USERS_USERNAME');
+ await queryRunner.dropIndex('users', 'IDX_USERS_ROLE');
+
+ // Drop table
+ await queryRunner.dropTable('users');
+ }
+}
+
diff --git a/server/src/products/products.service.spec.ts b/server/src/products/products.service.spec.ts
new file mode 100644
index 0000000..12712e8
--- /dev/null
+++ b/server/src/products/products.service.spec.ts
@@ -0,0 +1,199 @@
+import { Test, TestingModule } from "@nestjs/testing";
+import { ProductsService } from "./products.service";
+import { getRepositoryToken } from "@nestjs/typeorm";
+import { NotFoundException } from "@nestjs/common";
+import { Product } from "../entities/product.entity";
+import { Repository } from "typeorm";
+
+// Mock external dependencies
+jest.mock("@google/genai");
+jest.mock("../config/gemini.config", () => ({
+ getGeminiApiKey: jest.fn(() => "mock-api-key"),
+}));
+jest.mock("../config/doubao.config", () => ({
+ getDoubaoConfig: jest.fn(() => null),
+ isDoubaoConfigured: jest.fn(() => false),
+}));
+
+describe("ProductsService", () => {
+ let service: ProductsService;
+ let productRepository: MockType>;
+
+ type MockType = {
+ [P in keyof T]?: jest.Mock;
+ };
+
+ const mockRepositoryFactory: () => MockType> = jest.fn(
+ () => ({
+ find: jest.fn(),
+ findOne: jest.fn(),
+ create: jest.fn(),
+ save: jest.fn(),
+ remove: jest.fn(),
+ }),
+ );
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ProductsService,
+ {
+ provide: getRepositoryToken(Product),
+ useFactory: mockRepositoryFactory,
+ },
+ ],
+ }).compile();
+
+ service = module.get(ProductsService);
+ productRepository = module.get(getRepositoryToken(Product));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("findAll", () => {
+ it("should return an array of products", async () => {
+ const mockProducts = [
+ {
+ id: 1,
+ name: { zh: "測試產品 1", en: "Test Product 1" },
+ description: "Description 1",
+ price: 100,
+ imageUrl: "http://example.com/image1.jpg",
+ artisanId: 1,
+ categoryId: 1,
+ craftId: 1,
+ stock: 10,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ {
+ id: 2,
+ name: { zh: "測試產品 2", en: "Test Product 2" },
+ description: "Description 2",
+ price: 200,
+ imageUrl: "http://example.com/image2.jpg",
+ artisanId: 1,
+ categoryId: 1,
+ craftId: 1,
+ stock: 5,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ] as any[];
+
+ productRepository.find.mockResolvedValue(mockProducts);
+
+ const result = await service.findAll();
+
+ expect(result).toEqual(mockProducts);
+ expect(result).toHaveLength(2);
+ expect(productRepository.find).toHaveBeenCalledTimes(1);
+ });
+
+ it("should return an empty array when no products exist", async () => {
+ productRepository.find.mockResolvedValue([]);
+
+ const result = await service.findAll();
+
+ expect(result).toEqual([]);
+ expect(result).toHaveLength(0);
+ expect(productRepository.find).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("findOne", () => {
+ const mockProduct = {
+ id: 1,
+ name: { zh: "測試產品", en: "Test Product" },
+ description: "Test Description",
+ price: 150,
+ imageUrl: "http://example.com/image.jpg",
+ artisanId: 1,
+ categoryId: 1,
+ craftId: 1,
+ stock: 10,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as any;
+
+ it("should return a product when found", async () => {
+ productRepository.findOne.mockResolvedValue(mockProduct);
+
+ const result = await service.findOne(1);
+
+ expect(result).toEqual(mockProduct);
+ expect(productRepository.findOne).toHaveBeenCalledWith({
+ where: { id: 1 },
+ });
+ });
+
+ it("should throw NotFoundException when product not found", async () => {
+ productRepository.findOne.mockResolvedValue(null);
+
+ await expect(service.findOne(999)).rejects.toThrow(
+ new NotFoundException("Product with ID 999 not found"),
+ );
+ });
+ });
+
+ describe("Helper Methods", () => {
+ describe("containsChineseCharacters", () => {
+ it("should detect Chinese characters in string", () => {
+ // Access private method through reflection for testing
+ const containsChinese = (service as any).containsChineseCharacters.bind(
+ service,
+ );
+
+ expect(containsChinese("麻雀")).toBe(true);
+ expect(containsChinese("Hello 世界")).toBe(true);
+ expect(containsChinese("Hello World")).toBe(false);
+ expect(containsChinese("123")).toBe(false);
+ });
+ });
+
+ describe("isMahjongCraft", () => {
+ it("should identify mahjong crafts correctly", () => {
+ // Access private method through reflection for testing
+ const isMahjong = (service as any).isMahjongCraft.bind(service);
+
+ expect(isMahjong("Mahjong Tile")).toBe(true);
+ expect(isMahjong("MAHJONG CRAFT")).toBe(true);
+ expect(isMahjong("麻雀工藝")).toBe(true);
+ expect(isMahjong("麻將牌")).toBe(true);
+ expect(isMahjong("Wood Carving")).toBe(false);
+ expect(isMahjong("Pottery")).toBe(false);
+ });
+ });
+ });
+
+ describe("generateCraftImage", () => {
+ it("should throw error when AI service is not configured", async () => {
+ // Mock getGeminiApiKey to return null for this test
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { getGeminiApiKey } = require("../config/gemini.config");
+ getGeminiApiKey.mockReturnValueOnce(null);
+
+ // Create a new service instance without AI configured
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ProductsService,
+ {
+ provide: getRepositoryToken(Product),
+ useFactory: mockRepositoryFactory,
+ },
+ ],
+ }).compile();
+
+ const serviceWithoutAI = module.get(ProductsService);
+
+ await expect(
+ serviceWithoutAI.generateCraftImage("Wood Carving", "Beautiful design"),
+ ).rejects.toThrow("The AI service is not configured on the server.");
+ });
+ });
+});
diff --git a/server/src/products/products.service.ts b/server/src/products/products.service.ts
index ecc4262..73e805a 100644
--- a/server/src/products/products.service.ts
+++ b/server/src/products/products.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GoogleGenAI } from '@google/genai';
@@ -10,6 +10,7 @@ import { generateMahjongTileReference } from '../utils/text-to-image.util';
@Injectable()
export class ProductsService {
+ private readonly logger = new Logger(ProductsService.name);
private ai?: GoogleGenAI;
constructor(
@@ -58,14 +59,14 @@ export class ProductsService {
if (isMahjong && hasChinesePrompt) {
// Extract only Chinese characters from the prompt (in case it includes pronunciation/explanation)
const chineseOnly = userPrompt.match(/[\u3400-\u9FFF]+/g)?.[0] || userPrompt;
- console.log('Generating mahjong tile reference image with Chinese text:', chineseOnly);
+ this.logger.log(`Generating mahjong tile reference image with Chinese text: ${chineseOnly}`);
referenceImage = generateMahjongTileReference(chineseOnly);
// DEBUG: Log reference image details
- console.log('Reference image generated:');
- console.log('- Format: PNG (base64 encoded)');
- console.log('- Size:', Math.round(referenceImage.length / 1024), 'KB');
- console.log('- Data URL length:', referenceImage.length, 'characters');
+ this.logger.log('Reference image generated:');
+ this.logger.log('- Format: PNG (base64 encoded)');
+ this.logger.log(`- Size: ${Math.round(referenceImage.length / 1024)} KB`);
+ this.logger.log(`- Data URL length: ${referenceImage.length} characters`);
// Enhance the prompt with explicit instructions
enhancedPrompt = `A hand-carved traditional Hong Kong mahjong tile with Chinese character(s) engraved vertically on it.
@@ -80,7 +81,7 @@ CRITICAL REQUIREMENTS:
7. Beautiful lighting that highlights the depth of the engraving
Reference image shows the correct Chinese characters to engrave. DO NOT change, simplify, or substitute any characters.`;
- console.log('Enhanced mahjong prompt for Chinese text:', chineseOnly);
+ this.logger.log(`Enhanced mahjong prompt for Chinese text: ${chineseOnly}`);
}
const fullPrompt = isMahjong && hasChinesePrompt
@@ -96,7 +97,7 @@ Reference image shows the correct Chinese characters to engrave. DO NOT change,
const imageUrl = await generateDoubaoImage(fullPrompt, doubaoConfig);
return { imageUrl };
} catch (doubaoError) {
- console.error('Error generating image with Doubao:', doubaoError);
+ this.logger.error('Error generating image with Doubao:', doubaoError);
if (!aiClient) {
throw doubaoError;
}
@@ -149,7 +150,7 @@ Remember: Character accuracy from the reference image is MORE IMPORTANT than art
throw new Error('AI failed to generate an image. Please try again later.');
}
} catch (error) {
- console.error('Error generating image with AI provider:', error);
+ this.logger.error('Error generating image with AI provider:', error);
if (error instanceof Error) {
throw new Error(error.message.includes('Doubao') ? error.message : `Gemini API Error: ${error.message}`);
}
diff --git a/server/src/seed.ts b/server/src/seed.ts
index 4c7e7a4..9a905ed 100644
--- a/server/src/seed.ts
+++ b/server/src/seed.ts
@@ -1,29 +1,38 @@
-import { NestFactory } from '@nestjs/core';
-import { AppModule } from './app.module';
-import { DataSource } from 'typeorm';
-import { Craft } from './entities/craft.entity';
-import { Product } from './entities/product.entity';
-import { Event } from './entities/event.entity';
-import { Artisan } from './entities/artisan.entity';
-import { Order } from './entities/order.entity';
-import { MessageThread } from './entities/message-thread.entity';
-const { CRAFTS, PRODUCTS, EVENTS, ARTISANS, ORDERS, MESSAGE_THREADS } = require('../../constants.cjs');
+import { NestFactory } from "@nestjs/core";
+import { AppModule } from "./app.module";
+import { DataSource } from "typeorm";
+import { Craft } from "./entities/craft.entity";
+import { Product } from "./entities/product.entity";
+import { Event } from "./entities/event.entity";
+import { Artisan } from "./entities/artisan.entity";
+import { Order } from "./entities/order.entity";
+import { MessageThread } from "./entities/message-thread.entity";
+/* eslint-disable @typescript-eslint/no-var-requires */
+const {
+ CRAFTS,
+ PRODUCTS,
+ EVENTS,
+ ARTISANS,
+ ORDERS,
+ MESSAGE_THREADS,
+} = require("../../constants.cjs");
+/* eslint-enable @typescript-eslint/no-var-requires */
async function seed() {
- console.log('🌱 Starting database seeding...');
-
+ console.log("🌱 Starting database seeding...");
+
const app = await NestFactory.createApplicationContext(AppModule);
const dataSource = app.get(DataSource);
try {
// Clear existing data (orders first due to foreign key constraints)
- await dataSource.query('DELETE FROM orders');
- await dataSource.query('DELETE FROM message_threads');
- await dataSource.query('DELETE FROM crafts');
- await dataSource.query('DELETE FROM products');
- await dataSource.query('DELETE FROM events');
- await dataSource.query('DELETE FROM artisans');
- console.log('✅ Cleared existing data');
+ await dataSource.query("DELETE FROM orders");
+ await dataSource.query("DELETE FROM message_threads");
+ await dataSource.query("DELETE FROM crafts");
+ await dataSource.query("DELETE FROM products");
+ await dataSource.query("DELETE FROM events");
+ await dataSource.query("DELETE FROM artisans");
+ console.log("✅ Cleared existing data");
// Seed crafts
const craftRepository = dataSource.getRepository(Craft);
@@ -55,9 +64,9 @@ async function seed() {
await messageRepository.save(MESSAGE_THREADS);
console.log(`✅ Seeded ${MESSAGE_THREADS.length} message threads`);
- console.log('🎉 Database seeding completed successfully!');
+ console.log("🎉 Database seeding completed successfully!");
} catch (error) {
- console.error('❌ Error seeding database:', error);
+ console.error("❌ Error seeding database:", error);
} finally {
await app.close();
}
diff --git a/server/typeorm.config.ts b/server/typeorm.config.ts
new file mode 100644
index 0000000..fa82c22
--- /dev/null
+++ b/server/typeorm.config.ts
@@ -0,0 +1,35 @@
+import { DataSource } from 'typeorm';
+import { config } from 'dotenv';
+import * as path from 'path';
+
+// Load environment variables
+config({ path: path.resolve(__dirname, '..', '.env.local') });
+config({ path: path.resolve(__dirname, '..', '.env') });
+
+const isPostgres = process.env.DATABASE_TYPE === 'postgres';
+
+export default new DataSource(
+ isPostgres
+ ? {
+ type: 'postgres',
+ host: process.env.DATABASE_HOST || 'localhost',
+ port: parseInt(process.env.DATABASE_PORT || '5432', 10),
+ username: process.env.DATABASE_USER || 'postgres',
+ password: process.env.DATABASE_PASSWORD || '',
+ database: process.env.DATABASE_NAME || 'craftshk',
+ entities: [path.join(__dirname, 'src', 'entities', '*.entity.{ts,js}')],
+ migrations: [path.join(__dirname, 'src', 'migrations', '*.{ts,js}')],
+ synchronize: false,
+ logging: true,
+ ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false,
+ }
+ : {
+ type: 'sqlite',
+ database: process.env.DATABASE_PATH || 'database.sqlite',
+ entities: [path.join(__dirname, 'src', 'entities', '*.entity.{ts,js}')],
+ migrations: [path.join(__dirname, 'src', 'migrations', '*.{ts,js}')],
+ synchronize: true,
+ logging: true,
+ },
+);
+
diff --git a/setup-cloudbuild-trigger.ps1 b/setup-cloudbuild-trigger.ps1
deleted file mode 100644
index bec8321..0000000
--- a/setup-cloudbuild-trigger.ps1
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env pwsh
-# Setup Cloud Build trigger for automatic deployment of frontend and backend
-
-Write-Host "🔧 Setting up Cloud Build Trigger for Craftscape HK" -ForegroundColor Cyan
-Write-Host "=================================================" -ForegroundColor Cyan
-Write-Host ""
-
-# Find gcloud installation
-$gcloudPaths = @(
- "$env:LOCALAPPDATA\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd",
- "$env:ProgramFiles\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd",
- "$env:ProgramFiles(x86)\Google\Cloud SDK\google-cloud-sdk\bin\gcloud.cmd"
-)
-
-$gcloudPath = $null
-foreach ($path in $gcloudPaths) {
- if (Test-Path $path) {
- $gcloudPath = $path
- break
- }
-}
-
-if (-not $gcloudPath) {
- # Try to find in PATH
- $gcloudCmd = Get-Command gcloud -ErrorAction SilentlyContinue
- if ($gcloudCmd) {
- $gcloudPath = $gcloudCmd.Source
- }
-}
-
-if (-not $gcloudPath) {
- Write-Host "❌ gcloud CLI not found. Please install Google Cloud SDK first." -ForegroundColor Red
- exit 1
-}
-
-Write-Host "Found gcloud at: $gcloudPath" -ForegroundColor Green
-
-# Create alias for easier use
-function Invoke-Gcloud {
- & $gcloudPath @args
-}
-
-# Set project
-$PROJECT_ID = "craftscapehk"
-Write-Host "📋 Setting project to: $PROJECT_ID" -ForegroundColor Yellow
-Invoke-Gcloud config set project $PROJECT_ID
-
-# Load Gemini API key from .env
-Write-Host ""
-Write-Host "🔑 Loading Gemini API key from .env..." -ForegroundColor Yellow
-$envFile = Join-Path $PSScriptRoot ".env"
-if (Test-Path $envFile) {
- $envContent = Get-Content $envFile
- $geminiApiKey = $envContent | Where-Object { $_ -match '^GEMINI_API_KEY=' } | ForEach-Object { $_.Split('=')[1].Trim('"').Trim("'") }
-
- if ($geminiApiKey) {
- Write-Host "✅ Found GEMINI_API_KEY in .env" -ForegroundColor Green
- } else {
- Write-Host "❌ GEMINI_API_KEY not found in .env file" -ForegroundColor Red
- exit 1
- }
-} else {
- Write-Host "❌ .env file not found" -ForegroundColor Red
- exit 1
-}
-
-# Enable required APIs
-Write-Host ""
-Write-Host "🔌 Enabling required APIs..." -ForegroundColor Yellow
-Invoke-Gcloud services enable cloudbuild.googleapis.com
-Invoke-Gcloud services enable run.googleapis.com
-Invoke-Gcloud services enable containerregistry.googleapis.com
-Write-Host "✅ APIs enabled" -ForegroundColor Green
-
-# Check if trigger already exists
-Write-Host ""
-Write-Host "🔍 Checking for existing trigger..." -ForegroundColor Yellow
-$existingTrigger = Invoke-Gcloud builds triggers list --filter="name:craftscape-deploy-main" --format="value(name)" 2>$null
-if ($existingTrigger) {
- Write-Host "⚠️ Trigger 'craftscape-deploy-main' already exists. Deleting..." -ForegroundColor Yellow
- Invoke-Gcloud builds triggers delete craftscape-deploy-main --quiet
-}
-
-# Create Cloud Build trigger
-Write-Host ""
-Write-Host "🚀 Creating Cloud Build trigger..." -ForegroundColor Yellow
-Write-Host "⚠️ Note: If GitHub repository is not connected, this will open a browser for authentication." -ForegroundColor Yellow
-Write-Host ""
-
-# Build substitutions string
-$substitutions = "_GEMINI_API_KEY=$geminiApiKey"
-
-# Try to create trigger with 2nd gen (GitHub App)
-Invoke-Gcloud builds triggers create github `
- --name="craftscape-deploy-main" `
- --description="Auto-deploy Craftscape frontend and backend on push to main" `
- --repo-name="CraftscapeHK" `
- --repo-owner="gracetyy" `
- --branch-pattern="^main$" `
- --build-config="cloudbuild.yaml" `
- --substitutions="$substitutions"
-
-if ($LASTEXITCODE -eq 0) {
- Write-Host ""
- Write-Host "✅ Cloud Build trigger created successfully!" -ForegroundColor Green
- Write-Host ""
- Write-Host "📝 Summary:" -ForegroundColor Cyan
- Write-Host " - Trigger name: craftscape-deploy-main" -ForegroundColor White
- Write-Host " - Repository: gracetyy/CraftscapeHK" -ForegroundColor White
- Write-Host " - Branch: main" -ForegroundColor White
- Write-Host " - Build config: cloudbuild.yaml" -ForegroundColor White
- Write-Host ""
- Write-Host " - Frontend: https://craftscape-frontend-.us-central1.run.app" -ForegroundColor Cyan
- Write-Host " - Backend: https://craftscape-backend-.us-central1.run.app" -ForegroundColor Cyan
- Write-Host ""
- Write-Host "📊 View builds: https://console.cloud.google.com/cloud-build/builds?project=$PROJECT_ID" -ForegroundColor Cyan
- Write-Host "⚙️ View triggers: https://console.cloud.google.com/cloud-build/triggers?project=$PROJECT_ID" -ForegroundColor Cyan
-} else {
- Write-Host ""
- Write-Host "❌ Failed to create trigger" -ForegroundColor Red
- exit 1
-}
diff --git a/views/AiStudio.tsx b/views/AiStudio.tsx
index d6edd19..c71c928 100644
--- a/views/AiStudio.tsx
+++ b/views/AiStudio.tsx
@@ -565,10 +565,10 @@ const AiStudio: React.FC = ({ craft, onClose }) => {
}}
>
{/* Museum-style Header */}
-