Initialize project and add containerization
This commit is contained in:
commit
cb34dd7350
|
|
@ -0,0 +1,12 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
errors.txt
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
API_BASE_PATH=/api
|
||||||
|
SWAGGER_PATH=/docs
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
JWT_ISSUER=gem-track-backend
|
||||||
|
JWT_AUDIENCE=gem-track-api
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=gem_track
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_SCHEMA=public
|
||||||
|
DB_SSL=false
|
||||||
|
TYPEORM_SYNCHRONIZE=false
|
||||||
|
TYPEORM_LOGGING=false
|
||||||
|
|
||||||
|
TENANCY_MODE=shared-schema
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage/
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Gem Track Backend - API Documentation Overview
|
||||||
|
|
||||||
|
Welcome to the backend API reference for **Gem Track**! This guide is designed to help frontend developers and API integrators understand the available resources, authentication flow, and how to start making requests locally.
|
||||||
|
|
||||||
|
## Detailed OpenAPI Reference
|
||||||
|
|
||||||
|
The backend uses **TSOA**, which means that our API routes and schemas are strongly-typed and automatically generated.
|
||||||
|
|
||||||
|
You can access the full Interactive Swagger API documentation by navigating to:
|
||||||
|
👉 **[http://localhost:3000/docs](http://localhost:3000/docs)**
|
||||||
|
|
||||||
|
*(Make sure you have started your Docker environment via `docker compose up` first)*
|
||||||
|
|
||||||
|
From the Swagger UI, you can:
|
||||||
|
- **Test Endpoints**: Execute API requests directly from your browser.
|
||||||
|
- **View Schemas**: Inspect the exact JSON payload shapes for requests and responses.
|
||||||
|
- **Download Specification**: Grab the raw `swagger.json` file to auto-generate frontend SDKs/clients using generic tools (like OpenAPI Generator or Orval).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Capabilities
|
||||||
|
|
||||||
|
The API exposes endpoints divided into distinct domains:
|
||||||
|
|
||||||
|
### 1. Tenancy (`/api/v1/tenants`)
|
||||||
|
Gem Track is a multi-tenant application. Every organization runs in its own tenant container.
|
||||||
|
- **`POST /api/v1/tenants`**: Onboard a new tenant (this sets up default DB rows, billing plans, roles, and permissions).
|
||||||
|
- **`GET /api/v1/tenants/{tenantId}`**: Fetch details about an active tenant.
|
||||||
|
|
||||||
|
### 2. Authentication (`/api/v1/auth`)
|
||||||
|
Tokens are issued via JWT and must be provided natively in the `Authorization` header (`Bearer <token>`).
|
||||||
|
- **`POST /api/v1/auth/register`**: Enroll the first administrator user for a new tenant.
|
||||||
|
- **`POST /api/v1/auth/login`**: Acquire a JWT credential by authenticating.
|
||||||
|
- **`GET /api/v1/auth/me`**: Returns profile info, active roles, and authorization scopes (permissions) for the current user.
|
||||||
|
|
||||||
|
### 3. User Management (`/api/v1/users`)
|
||||||
|
- **`POST /api/v1/users`**: Directly create a new user profile inside a tenant.
|
||||||
|
- **`POST /api/v1/users/invite`**: Issue an invitation to a new team member.
|
||||||
|
- **`PATCH /api/v1/users/{userId}/roles`**: Assign operational security roles (`tenant_admin` or `tenant_user`).
|
||||||
|
- **`PATCH /api/v1/users/{userId}/status`**: Adjust user standing (active/suspended).
|
||||||
|
- **`GET /api/v1/users/{userId}`**: Extract scoped profile details.
|
||||||
|
|
||||||
|
### 4. Billing (`/api/v1/billing`)
|
||||||
|
Tracks the assigned tier usage context logic.
|
||||||
|
- **`GET /api/v1/billing/subscription`**: Pull the current subscription parameters (e.g., Free vs Pro vs Enterprise limits).
|
||||||
|
- **`POST /api/v1/billing/subscription/plan`**: Downgrade/Upgrade the overarching plan context constraints.
|
||||||
|
- **`GET /api/v1/billing/history`**: Return paginated historical statement data.
|
||||||
|
|
||||||
|
### 5. Task Management (`/api/v1/tasks`)
|
||||||
|
Agile tracking mechanics for users operating within the ecosystem.
|
||||||
|
- **`POST /api/v1/tasks`**: Map a new task context.
|
||||||
|
- **`GET /api/v1/tasks`**: Read parameters, scope visibility based on user tenant constraints.
|
||||||
|
- **`PATCH /api/v1/tasks/{taskId}` & Sub-Tasks**: Mutate progress milestones spanning assignments.
|
||||||
|
|
||||||
|
### 6. Document Management (`/api/v1/documents`)
|
||||||
|
- **`POST /api/v1/documents`**: Implements multi-part form data uploads for handling physical file ingest scaling.
|
||||||
|
- **`GET /api/v1/documents`**: Retrieves associated files stored alongside records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### System Setup
|
||||||
|
1. **Create the Tenant**: Hit `POST /api/v1/tenants`
|
||||||
|
2. **Create the Admin User**: Hit `POST /api/v1/auth/register` using the UUID returned from step 1.
|
||||||
|
3. **Login in App**: Send credentials via `POST /api/v1/auth/login` and persist the generated `accessToken`.
|
||||||
|
|
||||||
|
### Authenticating Requests
|
||||||
|
Once you have the `accessToken` from login, all requests tagged with the lock icon in Swagger (which require the `bearerAuth` security schema) must include the following header:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <your-access-token>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Stage 1: Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install all dependencies (including devDependencies)
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code and config files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate TSOA specs/routes and build the project
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production stage
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy built files from the builder stage
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
# If TSOA requires the generated routes to be in src/shared, copy them as well
|
||||||
|
# Copy the entire src directory if needed for TSOA routes or other assets
|
||||||
|
# Actually, dist/main.js should have everything if bundled, but TSOA routes are usually required at runtime
|
||||||
|
COPY --from=builder /app/src/shared/interfaces/http/tsoa/generated ./src/shared/interfaces/http/tsoa/generated
|
||||||
|
|
||||||
|
# Expose the application port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Gem Track Backend Starter
|
||||||
|
|
||||||
|
This repository is scaffolded as a production-ready modular monolith using:
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- TypeScript
|
||||||
|
- Express
|
||||||
|
- TypeORM
|
||||||
|
- TSOA
|
||||||
|
- Swagger UI
|
||||||
|
|
||||||
|
## Architecture Goals
|
||||||
|
|
||||||
|
- Clean Architecture + Domain Driven Design
|
||||||
|
- Strict separation of `domain`, `application`, `infrastructure`, and `interfaces`
|
||||||
|
- Multi-tenant SaaS readiness
|
||||||
|
- Scalability through bounded contexts
|
||||||
|
- Swagger/OpenAPI documentation via TSOA
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
bootstrap/ # Composition root and dependency wiring
|
||||||
|
main.ts # Process startup and graceful shutdown
|
||||||
|
app.ts # Express application assembly
|
||||||
|
|
||||||
|
shared/
|
||||||
|
domain/ # Pure business abstractions
|
||||||
|
application/ # Cross-cutting use-case ports/contracts
|
||||||
|
infrastructure/ # DB, security, logging, config, messaging
|
||||||
|
interfaces/http/ # Middleware, presenters, TSOA, Swagger
|
||||||
|
|
||||||
|
modules/
|
||||||
|
tenancy/
|
||||||
|
domain/ # Aggregate, repository contract, events
|
||||||
|
application/ # Commands, queries, DTOs, handlers
|
||||||
|
infrastructure/ # TypeORM entity, mapper, repository impl
|
||||||
|
interfaces/http/controllers/ # TSOA controllers
|
||||||
|
|
||||||
|
types/ # Framework type augmentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Flow
|
||||||
|
|
||||||
|
Dependencies always point inward:
|
||||||
|
|
||||||
|
`interfaces -> application -> domain`
|
||||||
|
|
||||||
|
`infrastructure` implements ports defined by `application` or contracts defined by `domain`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `domain` does not import Express, TSOA, TypeORM, or database code.
|
||||||
|
- `application` orchestrates use cases and depends only on domain contracts and abstract ports.
|
||||||
|
- `interfaces` is transport-only and should never hold business rules.
|
||||||
|
- `repositories` return domain aggregates, not ORM entities.
|
||||||
|
- `TypeORM entities` stay in infrastructure and are mapped to/from domain aggregates.
|
||||||
|
|
||||||
|
## Multi-Tenant Strategy
|
||||||
|
|
||||||
|
The scaffold is built around a tenant-aware request context:
|
||||||
|
|
||||||
|
- `tenant.middleware` resolves tenant from headers, subdomain, or request metadata.
|
||||||
|
- `TenantContext` flows from the HTTP edge into application and infrastructure.
|
||||||
|
- `tenant-data-source.resolver.ts` centralizes tenancy-aware database access.
|
||||||
|
- Shared-schema is the default mode, but the resolver is intentionally shaped so you can grow into schema-per-tenant or database-per-tenant.
|
||||||
|
|
||||||
|
## Swagger
|
||||||
|
|
||||||
|
Swagger is exposed from:
|
||||||
|
|
||||||
|
- `GET /docs`
|
||||||
|
- `GET /swagger.json`
|
||||||
|
|
||||||
|
TSOA controller decorators are used for OpenAPI generation, and the generated artifacts are written to:
|
||||||
|
|
||||||
|
- `src/shared/interfaces/http/tsoa/generated/routes.ts`
|
||||||
|
- `src/shared/interfaces/http/tsoa/generated/swagger.json`
|
||||||
|
|
||||||
|
The repository also includes starter generated files so the scaffold is understandable before running code generation.
|
||||||
|
|
||||||
|
## Sample Module
|
||||||
|
|
||||||
|
The included `tenancy` bounded context demonstrates:
|
||||||
|
|
||||||
|
- Domain aggregate with business invariants
|
||||||
|
- Application command/query handlers
|
||||||
|
- Repository contract in the domain layer
|
||||||
|
- TypeORM repository implementation in infrastructure
|
||||||
|
- TSOA controller in interfaces
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
1. Install dependencies with `npm install`.
|
||||||
|
2. Generate TSOA artifacts with `npm run tsoa:spec && npm run tsoa:routes`.
|
||||||
|
3. Extend `modules/` with bounded contexts such as `identity-access`, `catalog`, `billing`, and `orders`.
|
||||||
|
4. Add migrations, outbox processing, authentication policies, and integration tests.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME:-gem_track}
|
||||||
|
- DB_USER=${DB_USER:-postgres}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
|
- API_BASE_PATH=${API_BASE_PATH:-/api}
|
||||||
|
- SWAGGER_PATH=${SWAGGER_PATH:-/docs}
|
||||||
|
- TYPEORM_SYNCHRONIZE=true
|
||||||
|
- DB_INITIALIZE_ON_BOOTSTRAP=true
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- gem-track-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME:-gem_track}
|
||||||
|
- POSTGRES_USER=${DB_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gem_track}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- gem-track-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gem-track-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Backend Architecture
|
||||||
|
|
||||||
|
## Layer Responsibilities
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
- Encapsulates business rules and invariants.
|
||||||
|
- Contains aggregates, entities, value objects, domain events, and repository interfaces.
|
||||||
|
- Has no knowledge of HTTP, databases, or frameworks.
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
- Implements use cases through command and query handlers.
|
||||||
|
- Coordinates domain models, authorization, transactional boundaries, and DTO shaping.
|
||||||
|
- Depends on domain contracts and abstract infrastructure ports.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Implements technical details such as TypeORM repositories, database connections, JWT, logging, caching, messaging, and external services.
|
||||||
|
- Maps ORM entities to domain aggregates.
|
||||||
|
- Can be swapped without changing use-case or domain logic.
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
- Adapts delivery mechanisms such as HTTP, queues, or scheduled jobs to application use cases.
|
||||||
|
- TSOA controllers parse requests and delegate to application handlers.
|
||||||
|
- Middleware resolves request-scoped concerns like tenant context, authentication, tracing, and error formatting.
|
||||||
|
|
||||||
|
## Interaction Flow
|
||||||
|
|
||||||
|
1. HTTP request reaches Express.
|
||||||
|
2. Middleware enriches the request with `RequestContext` and `TenantContext`.
|
||||||
|
3. TSOA controller transforms the request into a command/query DTO.
|
||||||
|
4. Application handler invokes domain behavior using repository contracts.
|
||||||
|
5. Infrastructure repository loads/persists aggregates through TypeORM entities and mappers.
|
||||||
|
6. Controller returns a response DTO.
|
||||||
|
7. Error middleware converts exceptions into consistent API responses.
|
||||||
|
|
||||||
|
## Multi-Tenant Notes
|
||||||
|
|
||||||
|
The recommended progression is:
|
||||||
|
|
||||||
|
1. Shared database / shared schema with `tenant_id` enforcement in repositories.
|
||||||
|
2. Schema-per-tenant for higher isolation.
|
||||||
|
3. Database-per-tenant for top-tier compliance or noisy-neighbor isolation.
|
||||||
|
|
||||||
|
Keep tenancy decisions at the edge and in infrastructure:
|
||||||
|
|
||||||
|
- Controllers should not manually enforce tenant filters.
|
||||||
|
- Application handlers should accept tenant context only when the use case is tenant-specific.
|
||||||
|
- Infrastructure repositories must apply tenant scope consistently.
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,49 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const srcDir = path.resolve(__dirname, 'src');
|
||||||
|
const applicationErrorPath = path.resolve(srcDir, 'shared/domain/errors/application-error');
|
||||||
|
|
||||||
|
function walkDir(dir, callback) {
|
||||||
|
fs.readdirSync(dir).forEach(file => {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
if (fs.statSync(fullPath).isDirectory()) {
|
||||||
|
walkDir(fullPath, callback);
|
||||||
|
} else if (fullPath.endsWith('.ts')) {
|
||||||
|
callback(fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
walkDir(path.resolve(srcDir, 'modules'), file => {
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
// Match any import containing application-error
|
||||||
|
const regex = /from\s+['"]([^'"]*application-error)['"]/g;
|
||||||
|
let matches = [...content.matchAll(regex)];
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
let changed = false;
|
||||||
|
for (const match of matches) {
|
||||||
|
const importPath = match[1];
|
||||||
|
// calculate correct relative path
|
||||||
|
const dirOfFile = path.dirname(file);
|
||||||
|
// Let's use posix for imports
|
||||||
|
let correctRelative = path.posix.relative(
|
||||||
|
dirOfFile.split(path.sep).join(path.posix.sep),
|
||||||
|
applicationErrorPath.split(path.sep).join(path.posix.sep)
|
||||||
|
);
|
||||||
|
if (!correctRelative.startsWith('.')) {
|
||||||
|
correctRelative = './' + correctRelative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importPath !== correctRelative) {
|
||||||
|
console.log(`Fixing ${path.relative(srcDir, file)}: ${importPath} -> ${correctRelative}`);
|
||||||
|
content = content.replace(match[0], `from "${correctRelative}"`);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "gem-track-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Industrial-grade backend starter using Node.js, TypeScript, TypeORM, TSOA, and Swagger.",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "npm run tsoa:spec && npm run tsoa:routes && tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"tsoa:spec": "tsoa spec",
|
||||||
|
"tsoa:routes": "tsoa routes"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1026.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.1026.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"tsoa": "^6.6.0",
|
||||||
|
"typeorm": "^0.3.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
import { getApplicationContext } from "./bootstrap/application-context";
|
||||||
|
import { createAuthMiddleware } from "./shared/interfaces/http/middlewares/auth.middleware";
|
||||||
|
import {
|
||||||
|
errorMiddleware,
|
||||||
|
notFoundMiddleware
|
||||||
|
} from "./shared/interfaces/http/middlewares/error.middleware";
|
||||||
|
import { requestContextMiddleware } from "./shared/interfaces/http/middlewares/request-context.middleware";
|
||||||
|
import { tenantMiddleware } from "./shared/interfaces/http/middlewares/tenant.middleware";
|
||||||
|
import { RegisterRoutes } from "./shared/interfaces/http/tsoa/generated/routes";
|
||||||
|
import {
|
||||||
|
createSwaggerRouter,
|
||||||
|
loadSwaggerDocument
|
||||||
|
} from "./shared/interfaces/http/tsoa/swagger";
|
||||||
|
|
||||||
|
export function createApp(): express.Express {
|
||||||
|
const context = getApplicationContext();
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use(createAuthMiddleware(context.jwtService, context.logger));
|
||||||
|
app.use(
|
||||||
|
tenantMiddleware(context.config.tenancy.mode, context.useCases.tenancy.resolveTenant)
|
||||||
|
);
|
||||||
|
app.use(requestContextMiddleware);
|
||||||
|
|
||||||
|
app.get("/swagger.json", (_request, response) => {
|
||||||
|
response.json(loadSwaggerDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
context.config.http.swaggerPath,
|
||||||
|
createSwaggerRouter("/swagger.json")
|
||||||
|
);
|
||||||
|
|
||||||
|
RegisterRoutes(app);
|
||||||
|
|
||||||
|
app.use(notFoundMiddleware);
|
||||||
|
app.use(errorMiddleware(context.logger));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
import { CreateUserHandler } from "../modules/identity-access/application/commands/create-user/create-user.handler";
|
||||||
|
import { LoginHandler } from "../modules/identity-access/application/commands/login/login.handler";
|
||||||
|
import { RegisterUserHandler } from "../modules/identity-access/application/commands/register-user/register-user.handler";
|
||||||
|
import { GetCurrentUserHandler } from "../modules/identity-access/application/queries/get-current-user/get-current-user.handler";
|
||||||
|
import { GetUserByIdHandler } from "../modules/identity-access/application/queries/get-user-by-id/get-user-by-id.handler";
|
||||||
|
import { AccessTokenService } from "../modules/identity-access/application/services/access-token.service";
|
||||||
|
import { AuthPrincipalFactory } from "../modules/identity-access/application/services/auth-principal.factory";
|
||||||
|
import { TypeOrmRoleRepository } from "../modules/identity-access/infrastructure/persistence/typeorm/repositories/typeorm-role.repository";
|
||||||
|
import { TypeOrmUserRepository } from "../modules/identity-access/infrastructure/persistence/typeorm/repositories/typeorm-user.repository";
|
||||||
|
import { IdentityAccessSeeder } from "../modules/identity-access/infrastructure/services/identity-access-seeder";
|
||||||
|
import { AddSubTaskHandler } from "../modules/task-management/application/commands/add-subtask/add-subtask.handler";
|
||||||
|
import { CreateTaskHandler } from "../modules/task-management/application/commands/create-task/create-task.handler";
|
||||||
|
import { DeleteSubTaskHandler } from "../modules/task-management/application/commands/delete-subtask/delete-subtask.handler";
|
||||||
|
import { DeleteTaskHandler } from "../modules/task-management/application/commands/delete-task/delete-task.handler";
|
||||||
|
import { UpdateSubTaskHandler } from "../modules/task-management/application/commands/update-subtask/update-subtask.handler";
|
||||||
|
import { UpdateTaskHandler } from "../modules/task-management/application/commands/update-task/update-task.handler";
|
||||||
|
import { GetTaskByIdHandler } from "../modules/task-management/application/queries/get-task-by-id/get-task-by-id.handler";
|
||||||
|
import { ListTasksHandler } from "../modules/task-management/application/queries/list-tasks/list-tasks.handler";
|
||||||
|
import { TypeOrmTaskRepository } from "../modules/task-management/infrastructure/persistence/typeorm/repositories/typeorm-task.repository";
|
||||||
|
import { OnboardTenantHandler } from "../modules/tenancy/application/commands/onboard-tenant/onboard-tenant.handler";
|
||||||
|
import { GetTenantByIdHandler } from "../modules/tenancy/application/queries/get-tenant-by-id/get-tenant-by-id.handler";
|
||||||
|
import { ResolveTenantHandler } from "../modules/tenancy/application/queries/resolve-tenant/resolve-tenant.handler";
|
||||||
|
import { TypeOrmTenantRepository } from "../modules/tenancy/infrastructure/persistence/typeorm/repositories/typeorm-tenant.repository";
|
||||||
|
import { AssignRolesHandler } from "../modules/identity-access/application/commands/assign-roles/assign-roles.handler";
|
||||||
|
import { InviteUserHandler } from "../modules/identity-access/application/commands/invite-user/invite-user.handler";
|
||||||
|
import { UpdateUserStatusHandler } from "../modules/identity-access/application/commands/update-user-status/update-user-status.handler";
|
||||||
|
import { ConsoleEmailSender } from "../modules/identity-access/infrastructure/services/console-email-sender";
|
||||||
|
import { DeleteDocumentHandler } from "../modules/document-management/application/commands/delete-document/delete-document.handler";
|
||||||
|
import { UpdateDocumentHandler } from "../modules/document-management/application/commands/update-document/update-document.handler";
|
||||||
|
import { UploadDocumentHandler } from "../modules/document-management/application/commands/upload-document/upload-document.handler";
|
||||||
|
import { DownloadDocumentHandler } from "../modules/document-management/application/queries/download-document/download-document.handler";
|
||||||
|
import { TypeOrmSubscriptionPlanRepository } from "../modules/billing/infrastructure/persistence/typeorm/repositories/typeorm-subscription-plan.repository";
|
||||||
|
import { TypeOrmTenantSubscriptionRepository } from "../modules/billing/infrastructure/persistence/typeorm/repositories/typeorm-tenant-subscription.repository";
|
||||||
|
import { TypeOrmBillingRecordRepository } from "../modules/billing/infrastructure/persistence/typeorm/repositories/typeorm-billing-record.repository";
|
||||||
|
import { InitializeSubscriptionHandler } from "../modules/billing/application/commands/initialize-subscription/initialize-subscription.handler";
|
||||||
|
import { ChangeSubscriptionPlanHandler } from "../modules/billing/application/commands/change-subscription-plan/change-subscription-plan.handler";
|
||||||
|
import { GetTenantSubscriptionHandler } from "../modules/billing/application/queries/get-tenant-subscription/get-tenant-subscription.handler";
|
||||||
|
import { GetBillingHistoryHandler } from "../modules/billing/application/queries/get-billing-history/get-billing-history.handler";
|
||||||
|
import { BillingSeeder } from "../modules/billing/infrastructure/services/billing-seeder";
|
||||||
|
import { MockPaymentProvider } from "../modules/billing/infrastructure/services/mock-payment-provider";
|
||||||
|
import { GetDocumentByIdHandler } from "../modules/document-management/application/queries/get-document-by-id/get-document-by-id.handler";
|
||||||
|
import { ListDocumentsHandler } from "../modules/document-management/application/queries/list-documents/list-documents.handler";
|
||||||
|
import { DocumentLinkValidatorService } from "../modules/document-management/application/services/document-link-validator.service";
|
||||||
|
import { TypeOrmDocumentRepository } from "../modules/document-management/infrastructure/persistence/typeorm/repositories/typeorm-document.repository";
|
||||||
|
import { LocalDocumentStorageService } from "../modules/document-management/infrastructure/storage/local-document-storage.service";
|
||||||
|
import { S3DocumentStorageService } from "../modules/document-management/infrastructure/storage/s3-document-storage.service";
|
||||||
|
import { loadAppConfig } from "../shared/infrastructure/config/env";
|
||||||
|
import { Logger } from "../shared/infrastructure/logging/logger";
|
||||||
|
import { TypeOrmDataSourceFactory } from "../shared/infrastructure/persistence/typeorm/data-source.factory";
|
||||||
|
import { TenantDataSourceResolver } from "../shared/infrastructure/persistence/typeorm/tenant-data-source.resolver";
|
||||||
|
import { BcryptPasswordHasher } from "../shared/infrastructure/security/bcrypt-password-hasher";
|
||||||
|
import { JwtService } from "../shared/infrastructure/security/jwt.service";
|
||||||
|
import { NodeCryptoIdGenerator } from "../shared/infrastructure/system/node-crypto-id-generator";
|
||||||
|
import { SystemClock } from "../shared/infrastructure/system/system-clock";
|
||||||
|
|
||||||
|
export interface ApplicationContext {
|
||||||
|
config: ReturnType<typeof loadAppConfig>;
|
||||||
|
logger: Logger;
|
||||||
|
jwtService: JwtService;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
shutdown: () => Promise<void>;
|
||||||
|
useCases: {
|
||||||
|
tenancy: {
|
||||||
|
onboardTenant: OnboardTenantHandler;
|
||||||
|
getTenantById: GetTenantByIdHandler;
|
||||||
|
resolveTenant: ResolveTenantHandler;
|
||||||
|
};
|
||||||
|
identityAccess: {
|
||||||
|
registerUser: RegisterUserHandler;
|
||||||
|
login: LoginHandler;
|
||||||
|
getCurrentUser: GetCurrentUserHandler;
|
||||||
|
createUser: CreateUserHandler;
|
||||||
|
inviteUser: InviteUserHandler;
|
||||||
|
assignRoles: AssignRolesHandler;
|
||||||
|
updateUserStatus: UpdateUserStatusHandler;
|
||||||
|
getUserById: GetUserByIdHandler;
|
||||||
|
};
|
||||||
|
billing: {
|
||||||
|
initializeSubscription: InitializeSubscriptionHandler;
|
||||||
|
changeSubscriptionPlan: ChangeSubscriptionPlanHandler;
|
||||||
|
getTenantSubscription: GetTenantSubscriptionHandler;
|
||||||
|
getBillingHistory: GetBillingHistoryHandler;
|
||||||
|
};
|
||||||
|
taskManagement: {
|
||||||
|
createTask: CreateTaskHandler;
|
||||||
|
updateTask: UpdateTaskHandler;
|
||||||
|
deleteTask: DeleteTaskHandler;
|
||||||
|
addSubTask: AddSubTaskHandler;
|
||||||
|
updateSubTask: UpdateSubTaskHandler;
|
||||||
|
deleteSubTask: DeleteSubTaskHandler;
|
||||||
|
getTaskById: GetTaskByIdHandler;
|
||||||
|
listTasks: ListTasksHandler;
|
||||||
|
};
|
||||||
|
documentManagement: {
|
||||||
|
uploadDocument: UploadDocumentHandler;
|
||||||
|
updateDocument: UpdateDocumentHandler;
|
||||||
|
deleteDocument: DeleteDocumentHandler;
|
||||||
|
downloadDocument: DownloadDocumentHandler;
|
||||||
|
getDocumentById: GetDocumentByIdHandler;
|
||||||
|
listDocuments: ListDocumentsHandler;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let applicationContext: ApplicationContext | null = null;
|
||||||
|
|
||||||
|
export async function buildApplicationContext(): Promise<ApplicationContext> {
|
||||||
|
const config = loadAppConfig();
|
||||||
|
const logger = new Logger(config.application.name);
|
||||||
|
const dataSourceFactory = new TypeOrmDataSourceFactory(config, logger);
|
||||||
|
const tenantDataSourceResolver = new TenantDataSourceResolver(
|
||||||
|
dataSourceFactory
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantRepository = new TypeOrmTenantRepository(tenantDataSourceResolver);
|
||||||
|
const subscriptionPlanRepository = new TypeOrmSubscriptionPlanRepository(tenantDataSourceResolver);
|
||||||
|
const tenantSubscriptionRepository = new TypeOrmTenantSubscriptionRepository(tenantDataSourceResolver);
|
||||||
|
const billingRecordRepository = new TypeOrmBillingRecordRepository(tenantDataSourceResolver);
|
||||||
|
|
||||||
|
const userRepository = new TypeOrmUserRepository(tenantDataSourceResolver);
|
||||||
|
const roleRepository = new TypeOrmRoleRepository(tenantDataSourceResolver);
|
||||||
|
const taskRepository = new TypeOrmTaskRepository(tenantDataSourceResolver);
|
||||||
|
const documentRepository = new TypeOrmDocumentRepository(tenantDataSourceResolver);
|
||||||
|
const identityAccessSeeder = new IdentityAccessSeeder(
|
||||||
|
tenantDataSourceResolver
|
||||||
|
);
|
||||||
|
const tenantProvisioner = identityAccessSeeder;
|
||||||
|
const billingSeeder = new BillingSeeder(
|
||||||
|
subscriptionPlanRepository,
|
||||||
|
new SystemClock(),
|
||||||
|
new NodeCryptoIdGenerator()
|
||||||
|
);
|
||||||
|
|
||||||
|
const clock = new SystemClock();
|
||||||
|
const idGenerator = new NodeCryptoIdGenerator();
|
||||||
|
const jwtService = new JwtService({
|
||||||
|
secret: config.security.jwtSecret,
|
||||||
|
expiresIn: config.security.jwtExpiresIn,
|
||||||
|
issuer: config.security.jwtIssuer,
|
||||||
|
audience: config.security.jwtAudience
|
||||||
|
});
|
||||||
|
const passwordHasher = new BcryptPasswordHasher(config.security.bcryptRounds);
|
||||||
|
const authPrincipalFactory = new AuthPrincipalFactory();
|
||||||
|
const accessTokenService = new AccessTokenService(
|
||||||
|
jwtService,
|
||||||
|
authPrincipalFactory
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailSender = new ConsoleEmailSender();
|
||||||
|
|
||||||
|
const documentStorage = config.storage.provider === "s3"
|
||||||
|
? new S3DocumentStorageService(config.storage.s3)
|
||||||
|
: new LocalDocumentStorageService(config.storage.local.basePath);
|
||||||
|
|
||||||
|
const documentLinkValidator = new DocumentLinkValidatorService(
|
||||||
|
userRepository,
|
||||||
|
taskRepository,
|
||||||
|
idGenerator
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUser = new CreateUserHandler(
|
||||||
|
userRepository,
|
||||||
|
roleRepository,
|
||||||
|
tenantRepository,
|
||||||
|
tenantSubscriptionRepository,
|
||||||
|
subscriptionPlanRepository,
|
||||||
|
passwordHasher,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeSubscription = new InitializeSubscriptionHandler(
|
||||||
|
tenantSubscriptionRepository,
|
||||||
|
subscriptionPlanRepository,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
);
|
||||||
|
|
||||||
|
const onboardTenant = new OnboardTenantHandler(
|
||||||
|
tenantRepository,
|
||||||
|
tenantProvisioner,
|
||||||
|
initializeSubscription,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
);
|
||||||
|
|
||||||
|
const context: ApplicationContext = {
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
jwtService,
|
||||||
|
initialize: async () => {
|
||||||
|
if (config.database.initializeOnBootstrap) {
|
||||||
|
await tenantDataSourceResolver.getPlatformDataSource();
|
||||||
|
await billingSeeder.seed();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Application context initialized", {
|
||||||
|
tenancyMode: config.tenancy.mode,
|
||||||
|
databaseInitialization: config.database.initializeOnBootstrap
|
||||||
|
? "eager"
|
||||||
|
: "lazy"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
shutdown: async () => {
|
||||||
|
await dataSourceFactory.destroy();
|
||||||
|
logger.info("Application context stopped");
|
||||||
|
},
|
||||||
|
useCases: {
|
||||||
|
tenancy: {
|
||||||
|
onboardTenant: onboardTenant,
|
||||||
|
getTenantById: new GetTenantByIdHandler(tenantRepository),
|
||||||
|
resolveTenant: new ResolveTenantHandler(tenantRepository)
|
||||||
|
},
|
||||||
|
identityAccess: {
|
||||||
|
registerUser: new RegisterUserHandler(
|
||||||
|
userRepository,
|
||||||
|
roleRepository,
|
||||||
|
tenantRepository,
|
||||||
|
passwordHasher,
|
||||||
|
clock,
|
||||||
|
idGenerator,
|
||||||
|
accessTokenService
|
||||||
|
),
|
||||||
|
login: new LoginHandler(
|
||||||
|
userRepository,
|
||||||
|
tenantRepository,
|
||||||
|
passwordHasher,
|
||||||
|
accessTokenService
|
||||||
|
),
|
||||||
|
getCurrentUser: new GetCurrentUserHandler(userRepository),
|
||||||
|
createUser: createUser,
|
||||||
|
inviteUser: new InviteUserHandler(
|
||||||
|
userRepository,
|
||||||
|
roleRepository,
|
||||||
|
tenantRepository,
|
||||||
|
tenantSubscriptionRepository,
|
||||||
|
subscriptionPlanRepository,
|
||||||
|
emailSender,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
),
|
||||||
|
assignRoles: new AssignRolesHandler(
|
||||||
|
userRepository,
|
||||||
|
roleRepository,
|
||||||
|
clock
|
||||||
|
),
|
||||||
|
updateUserStatus: new UpdateUserStatusHandler(
|
||||||
|
userRepository,
|
||||||
|
clock
|
||||||
|
),
|
||||||
|
getUserById: new GetUserByIdHandler(userRepository)
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
initializeSubscription,
|
||||||
|
changeSubscriptionPlan: new ChangeSubscriptionPlanHandler(
|
||||||
|
tenantSubscriptionRepository,
|
||||||
|
subscriptionPlanRepository,
|
||||||
|
billingRecordRepository,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
),
|
||||||
|
getTenantSubscription: new GetTenantSubscriptionHandler(
|
||||||
|
tenantSubscriptionRepository,
|
||||||
|
subscriptionPlanRepository
|
||||||
|
),
|
||||||
|
getBillingHistory: new GetBillingHistoryHandler(
|
||||||
|
billingRecordRepository
|
||||||
|
)
|
||||||
|
},
|
||||||
|
taskManagement: {
|
||||||
|
createTask: new CreateTaskHandler(
|
||||||
|
taskRepository,
|
||||||
|
tenantRepository,
|
||||||
|
userRepository,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
),
|
||||||
|
updateTask: new UpdateTaskHandler(
|
||||||
|
taskRepository,
|
||||||
|
userRepository,
|
||||||
|
clock
|
||||||
|
),
|
||||||
|
deleteTask: new DeleteTaskHandler(taskRepository),
|
||||||
|
addSubTask: new AddSubTaskHandler(
|
||||||
|
taskRepository,
|
||||||
|
userRepository,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
),
|
||||||
|
updateSubTask: new UpdateSubTaskHandler(
|
||||||
|
taskRepository,
|
||||||
|
userRepository,
|
||||||
|
clock
|
||||||
|
),
|
||||||
|
deleteSubTask: new DeleteSubTaskHandler(taskRepository, clock),
|
||||||
|
getTaskById: new GetTaskByIdHandler(taskRepository),
|
||||||
|
listTasks: new ListTasksHandler(taskRepository)
|
||||||
|
},
|
||||||
|
documentManagement: {
|
||||||
|
uploadDocument: new UploadDocumentHandler(
|
||||||
|
documentRepository,
|
||||||
|
tenantRepository,
|
||||||
|
userRepository,
|
||||||
|
documentStorage,
|
||||||
|
documentLinkValidator,
|
||||||
|
clock,
|
||||||
|
idGenerator
|
||||||
|
),
|
||||||
|
updateDocument: new UpdateDocumentHandler(
|
||||||
|
documentRepository,
|
||||||
|
documentLinkValidator,
|
||||||
|
clock
|
||||||
|
),
|
||||||
|
deleteDocument: new DeleteDocumentHandler(
|
||||||
|
documentRepository,
|
||||||
|
documentStorage
|
||||||
|
),
|
||||||
|
downloadDocument: new DownloadDocumentHandler(
|
||||||
|
documentRepository,
|
||||||
|
documentStorage
|
||||||
|
),
|
||||||
|
getDocumentById: new GetDocumentByIdHandler(documentRepository),
|
||||||
|
listDocuments: new ListDocumentsHandler(documentRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApplicationContext(context: ApplicationContext): void {
|
||||||
|
applicationContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApplicationContext(): ApplicationContext {
|
||||||
|
if (!applicationContext) {
|
||||||
|
throw new Error(
|
||||||
|
"Application context has not been initialized. Call buildApplicationContext first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationContext;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import "reflect-metadata";
|
||||||
|
|
||||||
|
import { createApp } from "./app";
|
||||||
|
import {
|
||||||
|
buildApplicationContext,
|
||||||
|
setApplicationContext
|
||||||
|
} from "./bootstrap/application-context";
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const context = await buildApplicationContext();
|
||||||
|
setApplicationContext(context);
|
||||||
|
await context.initialize();
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const server = app.listen(context.config.http.port, () => {
|
||||||
|
context.logger.info("HTTP server started", {
|
||||||
|
port: context.config.http.port,
|
||||||
|
apiBasePath: context.config.http.apiBasePath,
|
||||||
|
swaggerPath: context.config.http.swaggerPath
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
context.logger.info("Shutdown signal received", { signal });
|
||||||
|
|
||||||
|
server.close(async () => {
|
||||||
|
await context.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void shutdown("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void shutdown("SIGTERM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface ChangeSubscriptionPlanCommand {
|
||||||
|
tenantId: string;
|
||||||
|
planCode: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../../billing/domain/repositories/subscription-plan.repository";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../../billing/domain/repositories/tenant-subscription.repository";
|
||||||
|
import { ChangeSubscriptionPlanCommand } from "./change-subscription-plan.command";
|
||||||
|
import { BillingRecordRepository } from "../../../domain/repositories/billing-record.repository";
|
||||||
|
import { BillingRecordEntity } from "../../../domain/entities/billing-record.entity";
|
||||||
|
|
||||||
|
export class ChangeSubscriptionPlanHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly subscriptionRepository: TenantSubscriptionRepository,
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository,
|
||||||
|
private readonly billingRecordRepository: BillingRecordRepository,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: ChangeSubscriptionPlanCommand): Promise<void> {
|
||||||
|
const subscription = await this.subscriptionRepository.findByTenantId(command.tenantId);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new ApplicationError("Subscription not found", 404, "SUBSCRIPTION_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPlan = await this.planRepository.findByCode(command.planCode);
|
||||||
|
if (!newPlan) {
|
||||||
|
throw new ApplicationError(`Plan with code ${command.planCode} not found`, 404, "PLAN_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.clock.now();
|
||||||
|
subscription.changePlan(newPlan.id, now);
|
||||||
|
|
||||||
|
// Record billing entry if the plan has a price
|
||||||
|
if (newPlan.priceMonthly > 0) {
|
||||||
|
const billingRecord = BillingRecordEntity.create({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
amount: newPlan.priceMonthly,
|
||||||
|
currency: newPlan.currency,
|
||||||
|
status: "paid", // Auto-paid in this mock implementation
|
||||||
|
paidAt: now
|
||||||
|
});
|
||||||
|
await this.billingRecordRepository.save(billingRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface InitializeSubscriptionCommand {
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../../billing/domain/repositories/subscription-plan.repository";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../../billing/domain/repositories/tenant-subscription.repository";
|
||||||
|
import { TenantSubscriptionAggregate } from "../../../../billing/domain/aggregates/tenant-subscription.aggregate";
|
||||||
|
import { InitializeSubscriptionCommand } from "./initialize-subscription.command";
|
||||||
|
|
||||||
|
export class InitializeSubscriptionHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly subscriptionRepository: TenantSubscriptionRepository,
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: InitializeSubscriptionCommand): Promise<void> {
|
||||||
|
const existing = await this.subscriptionRepository.findByTenantId(command.tenantId);
|
||||||
|
if (existing) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
const freePlan = await this.planRepository.findByCode("free");
|
||||||
|
if (!freePlan) {
|
||||||
|
throw new ApplicationError("Default Free plan not found", 500, "BILLING_CONFIGURATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = TenantSubscriptionAggregate.initialize({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
planId: freePlan.id,
|
||||||
|
now: this.clock.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface PaymentProviderPort {
|
||||||
|
createCustomer(tenantId: string, email: string): Promise<string>;
|
||||||
|
createSubscription(customerId: string, planCode: string): Promise<{
|
||||||
|
subscriptionId: string;
|
||||||
|
status: string;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
}>;
|
||||||
|
cancelSubscription(subscriptionId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { BillingRecordRepository } from "../../../domain/repositories/billing-record.repository";
|
||||||
|
import { GetBillingHistoryQuery, BillingRecordResponseDto } from "./get-billing-history.query";
|
||||||
|
|
||||||
|
export class GetBillingHistoryHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly billingRecordRepository: BillingRecordRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(query: GetBillingHistoryQuery): Promise<BillingRecordResponseDto[]> {
|
||||||
|
const records = await this.billingRecordRepository.findByTenantId(query.tenantId);
|
||||||
|
|
||||||
|
return records.map(record => {
|
||||||
|
const primitives = record.toPrimitives();
|
||||||
|
return {
|
||||||
|
id: primitives.id,
|
||||||
|
amount: primitives.amount,
|
||||||
|
currency: primitives.currency,
|
||||||
|
status: primitives.status,
|
||||||
|
paidAt: primitives.paidAt?.toISOString(),
|
||||||
|
invoiceUrl: primitives.invoiceUrl,
|
||||||
|
createdAt: primitives.createdAt.toISOString()
|
||||||
|
};
|
||||||
|
}).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface GetBillingHistoryQuery {
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingRecordResponseDto {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
paidAt?: string;
|
||||||
|
invoiceUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../domain/repositories/subscription-plan.repository";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../domain/repositories/tenant-subscription.repository";
|
||||||
|
import { GetTenantSubscriptionQuery, TenantSubscriptionResponseDto } from "./get-tenant-subscription.query";
|
||||||
|
|
||||||
|
export class GetTenantSubscriptionHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly subscriptionRepository: TenantSubscriptionRepository,
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(query: GetTenantSubscriptionQuery): Promise<TenantSubscriptionResponseDto> {
|
||||||
|
const subscription = await this.subscriptionRepository.findByTenantId(query.tenantId);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new ApplicationError("Subscription not found for this tenant", 404, "SUBSCRIPTION_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await this.planRepository.findById(subscription.planId);
|
||||||
|
if (!plan) {
|
||||||
|
throw new ApplicationError("Plan not found", 500, "BILLING_CONFIGURATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
planName: plan.name,
|
||||||
|
planCode: plan.code,
|
||||||
|
maxUsers: plan.maxUsers,
|
||||||
|
status: subscription.status,
|
||||||
|
currentPeriodEnd: subscription.currentPeriodEnd.toISOString(),
|
||||||
|
priceMonthly: plan.priceMonthly,
|
||||||
|
currency: plan.currency
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface GetTenantSubscriptionQuery {
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSubscriptionResponseDto {
|
||||||
|
planName: string;
|
||||||
|
planCode: string;
|
||||||
|
maxUsers: number;
|
||||||
|
status: string;
|
||||||
|
currentPeriodEnd: string;
|
||||||
|
priceMonthly: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { AggregateRoot } from "../../../../shared/domain/base/aggregate-root";
|
||||||
|
import { DomainError } from "../../../../shared/domain/errors/domain-error";
|
||||||
|
|
||||||
|
export interface SubscriptionPlanPrimitives {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
maxUsers: number;
|
||||||
|
priceMonthly: number;
|
||||||
|
currency: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePlanProps {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
maxUsers: number;
|
||||||
|
priceMonthly: number;
|
||||||
|
currency: string;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionPlanAggregate extends AggregateRoot<string> {
|
||||||
|
private constructor(private readonly state: SubscriptionPlanPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(props: CreatePlanProps): SubscriptionPlanAggregate {
|
||||||
|
if (!props.code.trim()) {
|
||||||
|
throw new DomainError("Plan code is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.name.trim()) {
|
||||||
|
throw new DomainError("Plan name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.maxUsers <= 0 && props.maxUsers !== -1) {
|
||||||
|
throw new DomainError("Max users must be positive or -1 for unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubscriptionPlanAggregate({
|
||||||
|
id: props.id,
|
||||||
|
code: props.code.trim().toLowerCase(),
|
||||||
|
name: props.name.trim(),
|
||||||
|
maxUsers: props.maxUsers,
|
||||||
|
priceMonthly: props.priceMonthly,
|
||||||
|
currency: props.currency.toUpperCase(),
|
||||||
|
createdAt: props.now,
|
||||||
|
updatedAt: props.now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static rehydrate(primitives: SubscriptionPlanPrimitives): SubscriptionPlanAggregate {
|
||||||
|
return new SubscriptionPlanAggregate(primitives);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get code(): string {
|
||||||
|
return this.state.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name(): string {
|
||||||
|
return this.state.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get maxUsers(): number {
|
||||||
|
return this.state.maxUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get priceMonthly(): number {
|
||||||
|
return this.state.priceMonthly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currency(): string {
|
||||||
|
return this.state.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): SubscriptionPlanPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { AggregateRoot } from "../../../../shared/domain/base/aggregate-root";
|
||||||
|
import { DomainError } from "../../../../shared/domain/errors/domain-error";
|
||||||
|
|
||||||
|
export type SubscriptionStatus = "active" | "past_due" | "canceled";
|
||||||
|
|
||||||
|
export interface TenantSubscriptionPrimitives {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
customerId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitializeSubscriptionProps {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TenantSubscriptionAggregate extends AggregateRoot<string> {
|
||||||
|
private constructor(private readonly state: TenantSubscriptionPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static initialize(props: InitializeSubscriptionProps): TenantSubscriptionAggregate {
|
||||||
|
return new TenantSubscriptionAggregate({
|
||||||
|
id: props.id,
|
||||||
|
tenantId: props.tenantId,
|
||||||
|
planId: props.planId,
|
||||||
|
status: "active",
|
||||||
|
currentPeriodEnd: new Date(props.now.getTime() + 30 * 24 * 60 * 60 * 1000), // Default 30 days
|
||||||
|
createdAt: props.now,
|
||||||
|
updatedAt: props.now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static rehydrate(primitives: TenantSubscriptionPrimitives): TenantSubscriptionAggregate {
|
||||||
|
return new TenantSubscriptionAggregate(primitives);
|
||||||
|
}
|
||||||
|
|
||||||
|
public changePlan(newPlanId: string, now: Date): void {
|
||||||
|
if (this.state.planId === newPlanId) {
|
||||||
|
throw new DomainError("New plan must be different from the current plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.planId = newPlanId;
|
||||||
|
this.state.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateExternalIds(customerId: string, subscriptionId: string, now: Date): void {
|
||||||
|
this.state.customerId = customerId;
|
||||||
|
this.state.subscriptionId = subscriptionId;
|
||||||
|
this.state.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get tenantId(): string {
|
||||||
|
return this.state.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get planId(): string {
|
||||||
|
return this.state.planId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get status(): SubscriptionStatus {
|
||||||
|
return this.state.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentPeriodEnd(): Date {
|
||||||
|
return this.state.currentPeriodEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): TenantSubscriptionPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
export type BillingStatus = "paid" | "failed" | "pending";
|
||||||
|
|
||||||
|
export interface BillingRecordPrimitives {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: BillingStatus;
|
||||||
|
paidAt?: Date;
|
||||||
|
invoiceUrl?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BillingRecordEntity {
|
||||||
|
constructor(private readonly state: BillingRecordPrimitives) {}
|
||||||
|
|
||||||
|
public static create(props: Omit<BillingRecordPrimitives, "createdAt">): BillingRecordEntity {
|
||||||
|
return new BillingRecordEntity({
|
||||||
|
...props,
|
||||||
|
createdAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): BillingRecordPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
public get amount(): number { return this.state.amount; }
|
||||||
|
public get currency(): string { return this.state.currency; }
|
||||||
|
public get status(): BillingStatus { return this.state.status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { BillingRecordEntity } from "../entities/billing-record.entity";
|
||||||
|
|
||||||
|
export interface BillingRecordRepository {
|
||||||
|
save(record: BillingRecordEntity): Promise<void>;
|
||||||
|
findByTenantId(tenantId: string): Promise<BillingRecordEntity[]>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { SubscriptionPlanAggregate } from "../aggregates/subscription-plan.aggregate";
|
||||||
|
|
||||||
|
export interface SubscriptionPlanRepository {
|
||||||
|
save(plan: SubscriptionPlanAggregate): Promise<void>;
|
||||||
|
findById(id: string): Promise<SubscriptionPlanAggregate | null>;
|
||||||
|
findByCode(code: string): Promise<SubscriptionPlanAggregate | null>;
|
||||||
|
findAll(): Promise<SubscriptionPlanAggregate[]>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { TenantSubscriptionAggregate } from "../aggregates/tenant-subscription.aggregate";
|
||||||
|
|
||||||
|
export interface TenantSubscriptionRepository {
|
||||||
|
save(subscription: TenantSubscriptionAggregate): Promise<void>;
|
||||||
|
findByTenantId(tenantId: string): Promise<TenantSubscriptionAggregate | null>;
|
||||||
|
findById(id: string): Promise<TenantSubscriptionAggregate | null>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity({ name: "billing_records" })
|
||||||
|
export class BillingRecordOrmEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "tenant_id" })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "subscription_id" })
|
||||||
|
subscriptionId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "decimal", precision: 10, scale: 2 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 3 })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 20 })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", nullable: true, name: "paid_at" })
|
||||||
|
paidAt?: Date;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255, nullable: true, name: "invoice_url" })
|
||||||
|
invoiceUrl?: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "created_at" })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity({ name: "subscription_plans" })
|
||||||
|
export class SubscriptionPlanOrmEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 64, unique: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 128 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: "integer", name: "max_users" })
|
||||||
|
maxUsers!: number;
|
||||||
|
|
||||||
|
@Column({ type: "decimal", precision: 10, scale: 2, name: "price_monthly" })
|
||||||
|
priceMonthly!: number;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 3 })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "created_at" })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "updated_at" })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity({ name: "tenant_subscriptions" })
|
||||||
|
export class TenantSubscriptionOrmEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "tenant_id", unique: true })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "plan_id" })
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 20 })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "current_period_end" })
|
||||||
|
currentPeriodEnd!: Date;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 128, nullable: true, name: "customer_id" })
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 128, nullable: true, name: "subscription_id" })
|
||||||
|
subscriptionId?: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "created_at" })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "updated_at" })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { BillingStatus, BillingRecordEntity } from "../../../../domain/entities/billing-record.entity";
|
||||||
|
import { BillingRecordOrmEntity } from "../entities/billing-record.orm-entity";
|
||||||
|
|
||||||
|
export class BillingRecordMapper {
|
||||||
|
public static toPersistence(record: BillingRecordEntity): BillingRecordOrmEntity {
|
||||||
|
const primitives = record.toPrimitives();
|
||||||
|
const orm = new BillingRecordOrmEntity();
|
||||||
|
orm.id = primitives.id;
|
||||||
|
orm.tenantId = primitives.tenantId;
|
||||||
|
orm.subscriptionId = primitives.subscriptionId;
|
||||||
|
orm.amount = primitives.amount;
|
||||||
|
orm.currency = primitives.currency;
|
||||||
|
orm.status = primitives.status;
|
||||||
|
orm.paidAt = primitives.paidAt;
|
||||||
|
orm.invoiceUrl = primitives.invoiceUrl;
|
||||||
|
orm.createdAt = primitives.createdAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDomain(orm: BillingRecordOrmEntity): BillingRecordEntity {
|
||||||
|
return new BillingRecordEntity({
|
||||||
|
id: orm.id,
|
||||||
|
tenantId: orm.tenantId,
|
||||||
|
subscriptionId: orm.subscriptionId,
|
||||||
|
amount: Number(orm.amount),
|
||||||
|
currency: orm.currency,
|
||||||
|
status: orm.status as BillingStatus,
|
||||||
|
paidAt: orm.paidAt,
|
||||||
|
invoiceUrl: orm.invoiceUrl,
|
||||||
|
createdAt: orm.createdAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { SubscriptionPlanAggregate } from "../../../../domain/aggregates/subscription-plan.aggregate";
|
||||||
|
import { SubscriptionPlanOrmEntity } from "../entities/subscription-plan.orm-entity";
|
||||||
|
|
||||||
|
export class SubscriptionPlanMapper {
|
||||||
|
public static toPersistence(plan: SubscriptionPlanAggregate): SubscriptionPlanOrmEntity {
|
||||||
|
const primitives = plan.toPrimitives();
|
||||||
|
const orm = new SubscriptionPlanOrmEntity();
|
||||||
|
orm.id = primitives.id;
|
||||||
|
orm.code = primitives.code;
|
||||||
|
orm.name = primitives.name;
|
||||||
|
orm.maxUsers = primitives.maxUsers;
|
||||||
|
orm.priceMonthly = primitives.priceMonthly;
|
||||||
|
orm.currency = primitives.currency;
|
||||||
|
orm.createdAt = primitives.createdAt;
|
||||||
|
orm.updatedAt = primitives.updatedAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDomain(orm: SubscriptionPlanOrmEntity): SubscriptionPlanAggregate {
|
||||||
|
return SubscriptionPlanAggregate.rehydrate({
|
||||||
|
id: orm.id,
|
||||||
|
code: orm.code,
|
||||||
|
name: orm.name,
|
||||||
|
maxUsers: orm.maxUsers,
|
||||||
|
priceMonthly: Number(orm.priceMonthly),
|
||||||
|
currency: orm.currency,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
updatedAt: orm.updatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { SubscriptionStatus, TenantSubscriptionAggregate } from "../../../../domain/aggregates/tenant-subscription.aggregate";
|
||||||
|
import { TenantSubscriptionOrmEntity } from "../entities/tenant-subscription.orm-entity";
|
||||||
|
|
||||||
|
export class TenantSubscriptionMapper {
|
||||||
|
public static toPersistence(subscription: TenantSubscriptionAggregate): TenantSubscriptionOrmEntity {
|
||||||
|
const primitives = subscription.toPrimitives();
|
||||||
|
const orm = new TenantSubscriptionOrmEntity();
|
||||||
|
orm.id = primitives.id;
|
||||||
|
orm.tenantId = primitives.tenantId;
|
||||||
|
orm.planId = primitives.planId;
|
||||||
|
orm.status = primitives.status;
|
||||||
|
orm.currentPeriodEnd = primitives.currentPeriodEnd;
|
||||||
|
orm.customerId = primitives.customerId;
|
||||||
|
orm.subscriptionId = primitives.subscriptionId;
|
||||||
|
orm.createdAt = primitives.createdAt;
|
||||||
|
orm.updatedAt = primitives.updatedAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDomain(orm: TenantSubscriptionOrmEntity): TenantSubscriptionAggregate {
|
||||||
|
return TenantSubscriptionAggregate.rehydrate({
|
||||||
|
id: orm.id,
|
||||||
|
tenantId: orm.tenantId,
|
||||||
|
planId: orm.planId,
|
||||||
|
status: orm.status as SubscriptionStatus,
|
||||||
|
currentPeriodEnd: orm.currentPeriodEnd,
|
||||||
|
customerId: orm.customerId,
|
||||||
|
subscriptionId: orm.subscriptionId,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
updatedAt: orm.updatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Repository as TypeOrmRepository } from "typeorm";
|
||||||
|
import { TenantDataSourceResolver } from "../../../../../../shared/infrastructure/persistence/typeorm/tenant-data-source.resolver";
|
||||||
|
import { BillingRecordEntity } from "../../../../domain/entities/billing-record.entity";
|
||||||
|
import { BillingRecordRepository } from "../../../../domain/repositories/billing-record.repository";
|
||||||
|
import { BillingRecordOrmEntity } from "../entities/billing-record.orm-entity";
|
||||||
|
import { BillingRecordMapper } from "../mappers/billing-record.mapper";
|
||||||
|
|
||||||
|
export class TypeOrmBillingRecordRepository implements BillingRecordRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDataSourceResolver: TenantDataSourceResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async save(record: BillingRecordEntity): Promise<void> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
await repository.save(BillingRecordMapper.toPersistence(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByTenantId(tenantId: string): Promise<BillingRecordEntity[]> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entities = await repository.findBy({ tenantId });
|
||||||
|
return entities.map(BillingRecordMapper.toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRepository(): Promise<TypeOrmRepository<BillingRecordOrmEntity>> {
|
||||||
|
const dataSource = await this.tenantDataSourceResolver.getPlatformDataSource();
|
||||||
|
return dataSource.getRepository(BillingRecordOrmEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Repository as TypeOrmRepository } from "typeorm";
|
||||||
|
import { TenantDataSourceResolver } from "../../../../../../shared/infrastructure/persistence/typeorm/tenant-data-source.resolver";
|
||||||
|
import { SubscriptionPlanAggregate } from "../../../../domain/aggregates/subscription-plan.aggregate";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../../domain/repositories/subscription-plan.repository";
|
||||||
|
import { SubscriptionPlanOrmEntity } from "../entities/subscription-plan.orm-entity";
|
||||||
|
import { SubscriptionPlanMapper } from "../mappers/subscription-plan.mapper";
|
||||||
|
|
||||||
|
export class TypeOrmSubscriptionPlanRepository implements SubscriptionPlanRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDataSourceResolver: TenantDataSourceResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async save(plan: SubscriptionPlanAggregate): Promise<void> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
await repository.save(SubscriptionPlanMapper.toPersistence(plan));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string): Promise<SubscriptionPlanAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOneBy({ id });
|
||||||
|
return entity ? SubscriptionPlanMapper.toDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByCode(code: string): Promise<SubscriptionPlanAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOneBy({ code: code.toLowerCase() });
|
||||||
|
return entity ? SubscriptionPlanMapper.toDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findAll(): Promise<SubscriptionPlanAggregate[]> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entities = await repository.find();
|
||||||
|
return entities.map(SubscriptionPlanMapper.toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRepository(): Promise<TypeOrmRepository<SubscriptionPlanOrmEntity>> {
|
||||||
|
const dataSource = await this.tenantDataSourceResolver.getPlatformDataSource();
|
||||||
|
return dataSource.getRepository(SubscriptionPlanOrmEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Repository as TypeOrmRepository } from "typeorm";
|
||||||
|
import { TenantDataSourceResolver } from "../../../../../../shared/infrastructure/persistence/typeorm/tenant-data-source.resolver";
|
||||||
|
import { TenantSubscriptionAggregate } from "../../../../domain/aggregates/tenant-subscription.aggregate";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../../domain/repositories/tenant-subscription.repository";
|
||||||
|
import { TenantSubscriptionOrmEntity } from "../entities/tenant-subscription.orm-entity";
|
||||||
|
import { TenantSubscriptionMapper } from "../mappers/tenant-subscription.mapper";
|
||||||
|
|
||||||
|
export class TypeOrmTenantSubscriptionRepository implements TenantSubscriptionRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDataSourceResolver: TenantDataSourceResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async save(subscription: TenantSubscriptionAggregate): Promise<void> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
await repository.save(TenantSubscriptionMapper.toPersistence(subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByTenantId(tenantId: string): Promise<TenantSubscriptionAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOneBy({ tenantId });
|
||||||
|
return entity ? TenantSubscriptionMapper.toDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string): Promise<TenantSubscriptionAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOneBy({ id });
|
||||||
|
return entity ? TenantSubscriptionMapper.toDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRepository(): Promise<TypeOrmRepository<TenantSubscriptionOrmEntity>> {
|
||||||
|
const dataSource = await this.tenantDataSourceResolver.getPlatformDataSource();
|
||||||
|
return dataSource.getRepository(TenantSubscriptionOrmEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ClockPort } from "../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { SubscriptionPlanAggregate } from "../../domain/aggregates/subscription-plan.aggregate";
|
||||||
|
import { SubscriptionPlanRepository } from "../../domain/repositories/subscription-plan.repository";
|
||||||
|
|
||||||
|
export class BillingSeeder {
|
||||||
|
constructor(
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async seed(): Promise<void> {
|
||||||
|
const defaultPlans = [
|
||||||
|
{
|
||||||
|
code: "free",
|
||||||
|
name: "Free Plan",
|
||||||
|
maxUsers: 5,
|
||||||
|
priceMonthly: 0,
|
||||||
|
currency: "USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "pro",
|
||||||
|
name: "Pro Plan",
|
||||||
|
maxUsers: 50,
|
||||||
|
priceMonthly: 29.99,
|
||||||
|
currency: "USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "enterprise",
|
||||||
|
name: "Enterprise Plan",
|
||||||
|
maxUsers: -1, // Unlimited
|
||||||
|
priceMonthly: 99.99,
|
||||||
|
currency: "USD"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const planData of defaultPlans) {
|
||||||
|
const existing = await this.planRepository.findByCode(planData.code);
|
||||||
|
if (!existing) {
|
||||||
|
const plan = SubscriptionPlanAggregate.create({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
now: this.clock.now(),
|
||||||
|
...planData
|
||||||
|
});
|
||||||
|
await this.planRepository.save(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { PaymentProviderPort } from "../../application/ports/payment-provider.port";
|
||||||
|
|
||||||
|
export class MockPaymentProvider implements PaymentProviderPort {
|
||||||
|
public async createCustomer(tenantId: string, email: string): Promise<string> {
|
||||||
|
return `cus_mock_${tenantId.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSubscription(customerId: string, planCode: string): Promise<{
|
||||||
|
subscriptionId: string;
|
||||||
|
status: string;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
}> {
|
||||||
|
return {
|
||||||
|
subscriptionId: `sub_mock_${planCode}_${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cancelSubscription(subscriptionId: string): Promise<void> {
|
||||||
|
console.log(`Mock: Canceling subscription ${subscriptionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Body, Controller, Get, Patch, Post, Request, Route, Security, Tags } from "tsoa";
|
||||||
|
import { GetTenantSubscriptionHandler } from "../../../application/queries/get-tenant-subscription/get-tenant-subscription.handler";
|
||||||
|
import { ChangeSubscriptionPlanHandler } from "../../../application/commands/change-subscription-plan/change-subscription-plan.handler";
|
||||||
|
import { GetBillingHistoryHandler } from "../../../application/queries/get-billing-history/get-billing-history.handler";
|
||||||
|
import { TenantSubscriptionResponseDto } from "../../../application/queries/get-tenant-subscription/get-tenant-subscription.query";
|
||||||
|
import { BillingRecordResponseDto } from "../../../application/queries/get-billing-history/get-billing-history.query";
|
||||||
|
|
||||||
|
interface ChangePlanRequestDto {
|
||||||
|
planCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Route("v1/billing")
|
||||||
|
@Tags("Billing")
|
||||||
|
@Security("bearerAuth")
|
||||||
|
export class BillingController extends Controller {
|
||||||
|
constructor(
|
||||||
|
private readonly getSubscriptionHandler: GetTenantSubscriptionHandler,
|
||||||
|
private readonly changePlanHandler: ChangeSubscriptionPlanHandler,
|
||||||
|
private readonly getHistoryHandler: GetBillingHistoryHandler
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("subscription")
|
||||||
|
public async getSubscription(
|
||||||
|
@Request() request: any
|
||||||
|
): Promise<TenantSubscriptionResponseDto> {
|
||||||
|
return this.getSubscriptionHandler.execute({
|
||||||
|
tenantId: request.principal.tenantId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("subscription/plan")
|
||||||
|
public async changePlan(
|
||||||
|
@Request() request: any,
|
||||||
|
@Body() body: ChangePlanRequestDto
|
||||||
|
): Promise<void> {
|
||||||
|
await this.changePlanHandler.execute({
|
||||||
|
tenantId: request.principal.tenantId,
|
||||||
|
planCode: body.planCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("history")
|
||||||
|
public async getHistory(
|
||||||
|
@Request() request: any
|
||||||
|
): Promise<BillingRecordResponseDto[]> {
|
||||||
|
return this.getHistoryHandler.execute({
|
||||||
|
tenantId: request.principal.tenantId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface DeleteDocumentCommand {
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { DocumentStoragePort } from "../../ports/document-storage.port";
|
||||||
|
import { DeleteDocumentCommand } from "./delete-document.command";
|
||||||
|
|
||||||
|
export class DeleteDocumentHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly documentRepository: DocumentRepository,
|
||||||
|
private readonly documentStorage: DocumentStoragePort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: DeleteDocumentCommand): Promise<void> {
|
||||||
|
const document = await this.documentRepository.findByIdForTenant(
|
||||||
|
command.tenantId,
|
||||||
|
command.documentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new ApplicationError("Document not found", 404, "DOCUMENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.documentStorage.delete(command.tenantId, document.storageKey);
|
||||||
|
await this.documentRepository.deleteForTenant(command.tenantId, command.documentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface UpdateDocumentCommand {
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
linkedTaskIds?: string[];
|
||||||
|
linkedSubTaskIds?: string[];
|
||||||
|
linkedUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
|
||||||
|
export class UpdateDocumentRequestDto {
|
||||||
|
tenantId!: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
linkedTaskIds?: string[];
|
||||||
|
linkedSubTaskIds?: string[];
|
||||||
|
linkedUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DocumentResponseDto };
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { toDocumentResponse, DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
import { DocumentLinkValidatorService } from "../../services/document-link-validator.service";
|
||||||
|
import { UpdateDocumentCommand } from "./update-document.command";
|
||||||
|
|
||||||
|
export class UpdateDocumentHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly documentRepository: DocumentRepository,
|
||||||
|
private readonly documentLinkValidator: DocumentLinkValidatorService,
|
||||||
|
private readonly clock: ClockPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: UpdateDocumentCommand): Promise<DocumentResponseDto> {
|
||||||
|
const document = await this.documentRepository.findByIdForTenant(
|
||||||
|
command.tenantId,
|
||||||
|
command.documentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new ApplicationError("Document not found", 404, "DOCUMENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.clock.now();
|
||||||
|
const links = await this.documentLinkValidator.buildLinks(
|
||||||
|
command.tenantId,
|
||||||
|
command.documentId,
|
||||||
|
{
|
||||||
|
linkedTaskIds: command.linkedTaskIds,
|
||||||
|
linkedSubTaskIds: command.linkedSubTaskIds,
|
||||||
|
linkedUserIds: command.linkedUserIds
|
||||||
|
},
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
document.updateMetadata({
|
||||||
|
title: command.title,
|
||||||
|
description: command.description,
|
||||||
|
metadata: command.metadata,
|
||||||
|
tags: command.tags,
|
||||||
|
links,
|
||||||
|
now
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.documentRepository.save(document);
|
||||||
|
|
||||||
|
return toDocumentResponse(document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export interface UploadDocumentCommand {
|
||||||
|
tenantId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
linkedTaskIds?: string[];
|
||||||
|
linkedSubTaskIds?: string[];
|
||||||
|
linkedUserIds?: string[];
|
||||||
|
uploadedByUserId: string;
|
||||||
|
file: {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalFileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { UploadedBinary } from "../../ports/document-storage.port";
|
||||||
|
import { DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
|
||||||
|
export class UploadDocumentRequestDto {
|
||||||
|
tenantId!: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, string> | string;
|
||||||
|
tags?: string[] | string;
|
||||||
|
linkedTaskIds?: string[] | string;
|
||||||
|
linkedSubTaskIds?: string[] | string;
|
||||||
|
linkedUserIds?: string[] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadDocumentFileDto extends UploadedBinary {}
|
||||||
|
|
||||||
|
export { DocumentResponseDto };
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { UserRepository } from "../../../../identity-access/domain/repositories/user.repository";
|
||||||
|
import { TenantRepository } from "../../../../tenancy/domain/repositories/tenant.repository";
|
||||||
|
import { DocumentAggregate } from "../../../domain/aggregates/document.aggregate";
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { toDocumentResponse, DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
import { DocumentStoragePort, StoredDocumentDescriptor } from "../../ports/document-storage.port";
|
||||||
|
import { DocumentLinkValidatorService } from "../../services/document-link-validator.service";
|
||||||
|
import { UploadDocumentCommand } from "./upload-document.command";
|
||||||
|
|
||||||
|
export class UploadDocumentHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly documentRepository: DocumentRepository,
|
||||||
|
private readonly tenantRepository: TenantRepository,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly documentStorage: DocumentStoragePort,
|
||||||
|
private readonly documentLinkValidator: DocumentLinkValidatorService,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: UploadDocumentCommand): Promise<DocumentResponseDto> {
|
||||||
|
const tenant = await this.tenantRepository.findById(command.tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new ApplicationError("Tenant not found", 404, "TENANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploader = await this.userRepository.findByIdForTenant(
|
||||||
|
command.tenantId,
|
||||||
|
command.uploadedByUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploader) {
|
||||||
|
throw new ApplicationError("Uploader does not belong to the tenant", 403, "INVALID_UPLOADER");
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = this.idGenerator.generate();
|
||||||
|
const now = this.clock.now();
|
||||||
|
const links = await this.documentLinkValidator.buildLinks(
|
||||||
|
command.tenantId,
|
||||||
|
documentId,
|
||||||
|
{
|
||||||
|
linkedTaskIds: command.linkedTaskIds,
|
||||||
|
linkedSubTaskIds: command.linkedSubTaskIds,
|
||||||
|
linkedUserIds: command.linkedUserIds
|
||||||
|
},
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
let storedDocument: StoredDocumentDescriptor | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
storedDocument = await this.documentStorage.upload(
|
||||||
|
command.tenantId,
|
||||||
|
documentId,
|
||||||
|
command.file
|
||||||
|
);
|
||||||
|
|
||||||
|
const document = DocumentAggregate.upload({
|
||||||
|
id: documentId,
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
title: command.title,
|
||||||
|
description: command.description,
|
||||||
|
originalFileName: command.file.originalFileName,
|
||||||
|
mimeType: command.file.mimeType,
|
||||||
|
sizeBytes: command.file.sizeBytes,
|
||||||
|
storageProvider: storedDocument.storageProvider,
|
||||||
|
storageKey: storedDocument.storageKey,
|
||||||
|
checksumSha256: storedDocument.checksumSha256,
|
||||||
|
uploadedByUserId: command.uploadedByUserId,
|
||||||
|
metadata: command.metadata,
|
||||||
|
tags: command.tags,
|
||||||
|
links,
|
||||||
|
now
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.documentRepository.save(document);
|
||||||
|
|
||||||
|
return toDocumentResponse(document);
|
||||||
|
} catch (error) {
|
||||||
|
if (storedDocument) {
|
||||||
|
await this.documentStorage.delete(command.tenantId, storedDocument.storageKey).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { ApplicationError } from "../../../../shared/domain/errors/application-error";
|
||||||
|
|
||||||
|
function parseMaybeJson(input: unknown): unknown {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(normalized.startsWith("{") && normalized.endsWith("}")) ||
|
||||||
|
(normalized.startsWith("[") && normalized.endsWith("]"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(normalized) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"Document payload contains invalid JSON",
|
||||||
|
422,
|
||||||
|
"INVALID_DOCUMENT_METADATA"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOptionalString(input: unknown): string | undefined {
|
||||||
|
if (input === undefined || input === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(input).trim();
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMetadata(input: unknown): Record<string, string> | undefined {
|
||||||
|
const normalizedInput = parseMaybeJson(input);
|
||||||
|
|
||||||
|
if (normalizedInput === undefined || normalizedInput === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof normalizedInput !== "object" || Array.isArray(normalizedInput)) {
|
||||||
|
throw new ApplicationError("metadata must be a key-value object", 422, "INVALID_DOCUMENT_METADATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(normalizedInput as Record<string, unknown>).reduce<Record<string, string>>(
|
||||||
|
(accumulator, [key, value]) => {
|
||||||
|
const normalizedKey = key.trim();
|
||||||
|
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return accumulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator[normalizedKey] = String(value);
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTags(input: unknown): string[] | undefined {
|
||||||
|
const normalizedInput = parseMaybeJson(input);
|
||||||
|
|
||||||
|
if (normalizedInput === undefined || normalizedInput === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(normalizedInput)) {
|
||||||
|
throw new ApplicationError("tags must be an array", 422, "INVALID_DOCUMENT_METADATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedInput.map((tag) => String(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLinkedIds(input: unknown): string[] | undefined {
|
||||||
|
const normalizedInput = parseMaybeJson(input);
|
||||||
|
|
||||||
|
if (normalizedInput === undefined || normalizedInput === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(normalizedInput)) {
|
||||||
|
throw new ApplicationError("link arrays must be arrays", 422, "INVALID_DOCUMENT_METADATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedInput.map((value) => String(value));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { DocumentAggregate } from "../../domain/aggregates/document.aggregate";
|
||||||
|
import { DocumentLinkTargetType } from "../../domain/entities/document-link.entity";
|
||||||
|
|
||||||
|
export class DocumentLinkResponseDto {
|
||||||
|
id!: string;
|
||||||
|
targetType!: DocumentLinkTargetType;
|
||||||
|
targetId!: string;
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
title!: string;
|
||||||
|
description?: string;
|
||||||
|
originalFileName!: string;
|
||||||
|
mimeType!: string;
|
||||||
|
sizeBytes!: number;
|
||||||
|
storageProvider!: string;
|
||||||
|
checksumSha256!: string;
|
||||||
|
uploadedByUserId!: string;
|
||||||
|
metadata!: Record<string, string>;
|
||||||
|
tags!: string[];
|
||||||
|
links!: DocumentLinkResponseDto[];
|
||||||
|
createdAt!: string;
|
||||||
|
updatedAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDocumentResponse(document: DocumentAggregate): DocumentResponseDto {
|
||||||
|
const primitives = document.toPrimitives();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: primitives.id,
|
||||||
|
tenantId: primitives.tenantId,
|
||||||
|
title: primitives.title,
|
||||||
|
description: primitives.description,
|
||||||
|
originalFileName: primitives.originalFileName,
|
||||||
|
mimeType: primitives.mimeType,
|
||||||
|
sizeBytes: primitives.sizeBytes,
|
||||||
|
storageProvider: primitives.storageProvider,
|
||||||
|
checksumSha256: primitives.checksumSha256,
|
||||||
|
uploadedByUserId: primitives.uploadedByUserId,
|
||||||
|
metadata: { ...primitives.metadata },
|
||||||
|
tags: [...primitives.tags],
|
||||||
|
links: primitives.links.map((link) => ({
|
||||||
|
id: link.id,
|
||||||
|
targetType: link.targetType,
|
||||||
|
targetId: link.targetId,
|
||||||
|
createdAt: link.createdAt.toISOString()
|
||||||
|
})),
|
||||||
|
createdAt: primitives.createdAt.toISOString(),
|
||||||
|
updatedAt: primitives.updatedAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
export interface UploadedBinary {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalFileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDocumentDescriptor {
|
||||||
|
storageProvider: string;
|
||||||
|
storageKey: string;
|
||||||
|
checksumSha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadedBinary {
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentStoragePort {
|
||||||
|
upload(tenantId: string, documentId: string, file: UploadedBinary): Promise<StoredDocumentDescriptor>;
|
||||||
|
download(tenantId: string, storageKey: string): Promise<DownloadedBinary>;
|
||||||
|
delete(tenantId: string, storageKey: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { DocumentStoragePort } from "../../ports/document-storage.port";
|
||||||
|
import { DownloadDocumentQuery } from "./download-document.query";
|
||||||
|
|
||||||
|
export interface DownloadDocumentResult {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadDocumentHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly documentRepository: DocumentRepository,
|
||||||
|
private readonly documentStorage: DocumentStoragePort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(query: DownloadDocumentQuery): Promise<DownloadDocumentResult> {
|
||||||
|
const document = await this.documentRepository.findByIdForTenant(
|
||||||
|
query.tenantId,
|
||||||
|
query.documentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new ApplicationError("Document not found", 404, "DOCUMENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = await this.documentStorage.download(query.tenantId, document.storageKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: document.originalFileName,
|
||||||
|
mimeType: document.mimeType,
|
||||||
|
sizeBytes: document.sizeBytes,
|
||||||
|
buffer: binary.buffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface DownloadDocumentQuery {
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { toDocumentResponse, DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
import { GetDocumentByIdQuery } from "./get-document-by-id.query";
|
||||||
|
|
||||||
|
export class GetDocumentByIdHandler {
|
||||||
|
constructor(private readonly documentRepository: DocumentRepository) {}
|
||||||
|
|
||||||
|
public async execute(query: GetDocumentByIdQuery): Promise<DocumentResponseDto> {
|
||||||
|
const document = await this.documentRepository.findByIdForTenant(
|
||||||
|
query.tenantId,
|
||||||
|
query.documentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new ApplicationError("Document not found", 404, "DOCUMENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toDocumentResponse(document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface GetDocumentByIdQuery {
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { DocumentRepository } from "../../../domain/repositories/document.repository";
|
||||||
|
import { toDocumentResponse, DocumentResponseDto } from "../../mappers/document-response.mapper";
|
||||||
|
import { ListDocumentsQuery } from "./list-documents.query";
|
||||||
|
|
||||||
|
export class ListDocumentsHandler {
|
||||||
|
constructor(private readonly documentRepository: DocumentRepository) {}
|
||||||
|
|
||||||
|
public async execute(query: ListDocumentsQuery): Promise<DocumentResponseDto[]> {
|
||||||
|
const documents = await this.documentRepository.listForTenant(query.tenantId, {
|
||||||
|
uploadedByUserId: query.uploadedByUserId,
|
||||||
|
targetType: query.targetType,
|
||||||
|
targetId: query.targetId
|
||||||
|
});
|
||||||
|
|
||||||
|
return documents.map(toDocumentResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { DocumentLinkTargetType } from "../../../domain/entities/document-link.entity";
|
||||||
|
|
||||||
|
export interface ListDocumentsQuery {
|
||||||
|
tenantId: string;
|
||||||
|
uploadedByUserId?: string;
|
||||||
|
targetType?: DocumentLinkTargetType;
|
||||||
|
targetId?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { DocumentLinkEntity, DocumentLinkTargetType } from "../../domain/entities/document-link.entity";
|
||||||
|
import { UserRepository } from "../../../identity-access/domain/repositories/user.repository";
|
||||||
|
import { TaskRepository } from "../../../task-management/domain/repositories/task.repository";
|
||||||
|
import { ApplicationError } from "../../../../shared/domain/errors/application-error";
|
||||||
|
import { IdGeneratorPort } from "../../../../shared/application/ports/id-generator.port";
|
||||||
|
|
||||||
|
export interface DocumentLinkInput {
|
||||||
|
linkedTaskIds?: string[];
|
||||||
|
linkedSubTaskIds?: string[];
|
||||||
|
linkedUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentLinkValidatorService {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly taskRepository: TaskRepository,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async buildLinks(
|
||||||
|
tenantId: string,
|
||||||
|
documentId: string,
|
||||||
|
links: DocumentLinkInput | undefined,
|
||||||
|
now: Date
|
||||||
|
): Promise<DocumentLinkEntity[]> {
|
||||||
|
const taskIds = uniqueValues(links?.linkedTaskIds);
|
||||||
|
const subTaskIds = uniqueValues(links?.linkedSubTaskIds);
|
||||||
|
const userIds = uniqueValues(links?.linkedUserIds);
|
||||||
|
|
||||||
|
for (const taskId of taskIds) {
|
||||||
|
const task = await this.taskRepository.findByIdForTenant(tenantId, taskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new ApplicationError("Linked task does not exist in tenant", 422, "INVALID_DOCUMENT_LINK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subTaskId of subTaskIds) {
|
||||||
|
const task = await this.taskRepository.findBySubTaskIdForTenant(tenantId, subTaskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new ApplicationError("Linked subtask does not exist in tenant", 422, "INVALID_DOCUMENT_LINK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const user = await this.userRepository.findByIdForTenant(tenantId, userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("Linked user does not exist in tenant", 422, "INVALID_DOCUMENT_LINK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...taskIds.map((taskId) =>
|
||||||
|
this.createLink(tenantId, documentId, "task", taskId, now)
|
||||||
|
),
|
||||||
|
...subTaskIds.map((subTaskId) =>
|
||||||
|
this.createLink(tenantId, documentId, "subtask", subTaskId, now)
|
||||||
|
),
|
||||||
|
...userIds.map((userId) =>
|
||||||
|
this.createLink(tenantId, documentId, "user", userId, now)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private createLink(
|
||||||
|
tenantId: string,
|
||||||
|
documentId: string,
|
||||||
|
targetType: DocumentLinkTargetType,
|
||||||
|
targetId: string,
|
||||||
|
now: Date
|
||||||
|
): DocumentLinkEntity {
|
||||||
|
return DocumentLinkEntity.create({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId,
|
||||||
|
documentId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values?: string[]): string[] {
|
||||||
|
return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { AggregateRoot } from "../../../../shared/domain/base/aggregate-root";
|
||||||
|
import { DomainError } from "../../../../shared/domain/errors/domain-error";
|
||||||
|
import { DocumentLinkEntity, DocumentLinkPrimitives, DocumentLinkTargetType } from "../entities/document-link.entity";
|
||||||
|
|
||||||
|
export interface DocumentPrimitives {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
originalFileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
storageProvider: string;
|
||||||
|
storageKey: string;
|
||||||
|
checksumSha256: string;
|
||||||
|
uploadedByUserId: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
tags: string[];
|
||||||
|
links: DocumentLinkPrimitives[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadDocumentProps {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
originalFileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
storageProvider: string;
|
||||||
|
storageKey: string;
|
||||||
|
checksumSha256: string;
|
||||||
|
uploadedByUserId: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
links?: DocumentLinkEntity[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentAggregate extends AggregateRoot<string> {
|
||||||
|
private readonly linksState: DocumentLinkEntity[];
|
||||||
|
|
||||||
|
private constructor(private readonly state: DocumentPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
this.linksState = state.links.map((link) => new DocumentLinkEntity(link));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static upload(props: UploadDocumentProps): DocumentAggregate {
|
||||||
|
const title = (props.title ?? props.originalFileName).trim();
|
||||||
|
|
||||||
|
if (!props.tenantId.trim()) {
|
||||||
|
throw new DomainError("Tenant id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.uploadedByUserId.trim()) {
|
||||||
|
throw new DomainError("Uploader user id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length < 1) {
|
||||||
|
throw new DomainError("Document title is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.sizeBytes <= 0) {
|
||||||
|
throw new DomainError("Document size must be greater than zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.storageKey.trim()) {
|
||||||
|
throw new DomainError("Storage key is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DocumentAggregate({
|
||||||
|
id: props.id,
|
||||||
|
tenantId: props.tenantId.trim(),
|
||||||
|
title,
|
||||||
|
description: props.description?.trim() || undefined,
|
||||||
|
originalFileName: props.originalFileName.trim(),
|
||||||
|
mimeType: props.mimeType.trim() || "application/octet-stream",
|
||||||
|
sizeBytes: props.sizeBytes,
|
||||||
|
storageProvider: props.storageProvider.trim(),
|
||||||
|
storageKey: props.storageKey.trim(),
|
||||||
|
checksumSha256: props.checksumSha256.trim(),
|
||||||
|
uploadedByUserId: props.uploadedByUserId.trim(),
|
||||||
|
metadata: normalizeMetadata(props.metadata),
|
||||||
|
tags: normalizeTags(props.tags),
|
||||||
|
links: (props.links ?? []).map((link) => link.toPrimitives()),
|
||||||
|
createdAt: props.now,
|
||||||
|
updatedAt: props.now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static rehydrate(primitives: DocumentPrimitives): DocumentAggregate {
|
||||||
|
return new DocumentAggregate({
|
||||||
|
...primitives,
|
||||||
|
metadata: normalizeMetadata(primitives.metadata),
|
||||||
|
tags: normalizeTags(primitives.tags)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateMetadata(props: {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
links?: DocumentLinkEntity[];
|
||||||
|
now: Date;
|
||||||
|
}): void {
|
||||||
|
if (props.title !== undefined) {
|
||||||
|
const title = props.title.trim();
|
||||||
|
|
||||||
|
if (title.length < 1) {
|
||||||
|
throw new DomainError("Document title is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.description !== undefined) {
|
||||||
|
this.state.description = props.description?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.metadata !== undefined) {
|
||||||
|
this.state.metadata = normalizeMetadata(props.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.tags !== undefined) {
|
||||||
|
this.state.tags = normalizeTags(props.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.links !== undefined) {
|
||||||
|
this.linksState.splice(0, this.linksState.length, ...props.links);
|
||||||
|
this.state.links = props.links.map((link) => link.toPrimitives());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.updatedAt = props.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasLink(targetType: DocumentLinkTargetType, targetId: string): boolean {
|
||||||
|
return this.linksState.some(
|
||||||
|
(link) => link.targetType === targetType && link.targetId === targetId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get tenantId(): string {
|
||||||
|
return this.state.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get storageKey(): string {
|
||||||
|
return this.state.storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get storageProvider(): string {
|
||||||
|
return this.state.storageProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get mimeType(): string {
|
||||||
|
return this.state.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get originalFileName(): string {
|
||||||
|
return this.state.originalFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get sizeBytes(): number {
|
||||||
|
return this.state.sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get uploadedByUserId(): string {
|
||||||
|
return this.state.uploadedByUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get links(): DocumentLinkEntity[] {
|
||||||
|
return [...this.linksState];
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): DocumentPrimitives {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
metadata: normalizeMetadata(this.state.metadata),
|
||||||
|
tags: normalizeTags(this.state.tags),
|
||||||
|
links: this.linksState.map((link) => link.toPrimitives())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadata(metadata?: Record<string, string>): Record<string, string> {
|
||||||
|
if (!metadata) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(metadata).reduce<Record<string, string>>((accumulator, [key, value]) => {
|
||||||
|
const normalizedKey = key.trim();
|
||||||
|
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return accumulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator[normalizedKey] = String(value);
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags?: string[]): string[] {
|
||||||
|
return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Entity } from "../../../../shared/domain/base/entity";
|
||||||
|
import { DomainError } from "../../../../shared/domain/errors/domain-error";
|
||||||
|
|
||||||
|
export type DocumentLinkTargetType = "task" | "subtask" | "user";
|
||||||
|
|
||||||
|
export interface DocumentLinkPrimitives {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
targetType: DocumentLinkTargetType;
|
||||||
|
targetId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateDocumentLinkProps {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
documentId: string;
|
||||||
|
targetType: DocumentLinkTargetType;
|
||||||
|
targetId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentLinkEntity extends Entity<string> {
|
||||||
|
constructor(private readonly state: DocumentLinkPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(props: CreateDocumentLinkProps): DocumentLinkEntity {
|
||||||
|
if (!props.targetId.trim()) {
|
||||||
|
throw new DomainError("Document link target id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DocumentLinkEntity({
|
||||||
|
id: props.id,
|
||||||
|
tenantId: props.tenantId.trim(),
|
||||||
|
documentId: props.documentId.trim(),
|
||||||
|
targetType: props.targetType,
|
||||||
|
targetId: props.targetId.trim(),
|
||||||
|
createdAt: props.createdAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get targetType(): DocumentLinkTargetType {
|
||||||
|
return this.state.targetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get targetId(): string {
|
||||||
|
return this.state.targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): DocumentLinkPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Repository } from "../../../../shared/domain/base/repository";
|
||||||
|
import { DocumentLinkTargetType } from "../entities/document-link.entity";
|
||||||
|
import { DocumentAggregate } from "../aggregates/document.aggregate";
|
||||||
|
|
||||||
|
export interface ListDocumentsFilter {
|
||||||
|
uploadedByUserId?: string;
|
||||||
|
targetType?: DocumentLinkTargetType;
|
||||||
|
targetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRepository extends Repository<DocumentAggregate, string> {
|
||||||
|
findByIdForTenant(tenantId: string, documentId: string): Promise<DocumentAggregate | null>;
|
||||||
|
listForTenant(tenantId: string, filter?: ListDocumentsFilter): Promise<DocumentAggregate[]>;
|
||||||
|
deleteForTenant(tenantId: string, documentId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Column, Entity, Index, ManyToOne, PrimaryColumn, JoinColumn } from "typeorm";
|
||||||
|
|
||||||
|
import { DocumentOrmEntity } from "./document.orm-entity";
|
||||||
|
|
||||||
|
@Entity({ name: "document_links" })
|
||||||
|
@Index(["tenantId", "documentId"])
|
||||||
|
@Index(["tenantId", "targetType", "targetId"])
|
||||||
|
export class DocumentLinkOrmEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "tenant_id" })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "document_id" })
|
||||||
|
documentId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 32, name: "target_type" })
|
||||||
|
targetType!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "target_id" })
|
||||||
|
targetId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "created_at" })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => DocumentOrmEntity, (document) => document.links, {
|
||||||
|
onDelete: "CASCADE"
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: "document_id" })
|
||||||
|
document!: DocumentOrmEntity;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Column, Entity, Index, OneToMany, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
|
import { DocumentLinkOrmEntity } from "./document-link.orm-entity";
|
||||||
|
|
||||||
|
@Entity({ name: "documents" })
|
||||||
|
@Index(["tenantId", "uploadedByUserId"])
|
||||||
|
@Index(["tenantId", "createdAt"])
|
||||||
|
export class DocumentOrmEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "tenant_id" })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: "text", nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", name: "original_file_name", length: 255 })
|
||||||
|
originalFileName!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", name: "mime_type", length: 255 })
|
||||||
|
mimeType!: string;
|
||||||
|
|
||||||
|
@Column({ type: "integer", name: "size_bytes" })
|
||||||
|
sizeBytes!: number;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", name: "storage_provider", length: 50 })
|
||||||
|
storageProvider!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", name: "storage_key", length: 512, unique: true })
|
||||||
|
storageKey!: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", name: "checksum_sha256", length: 64 })
|
||||||
|
checksumSha256!: string;
|
||||||
|
|
||||||
|
@Column({ type: "uuid", name: "uploaded_by_user_id" })
|
||||||
|
uploadedByUserId!: string;
|
||||||
|
|
||||||
|
@Column({ type: "jsonb", default: () => "'{}'::jsonb" })
|
||||||
|
metadata!: Record<string, string>;
|
||||||
|
|
||||||
|
@Column({ type: "jsonb", default: () => "'[]'::jsonb" })
|
||||||
|
tags!: string[];
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "created_at" })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", name: "updated_at" })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => DocumentLinkOrmEntity, (link) => link.document, {
|
||||||
|
cascade: true,
|
||||||
|
eager: true,
|
||||||
|
orphanedRowAction: "delete"
|
||||||
|
})
|
||||||
|
links!: DocumentLinkOrmEntity[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { DocumentAggregate } from "../../../../domain/aggregates/document.aggregate";
|
||||||
|
import { DocumentLinkOrmEntity } from "../entities/document-link.orm-entity";
|
||||||
|
import { DocumentOrmEntity } from "../entities/document.orm-entity";
|
||||||
|
|
||||||
|
export function toDocumentDomain(entity: DocumentOrmEntity): DocumentAggregate {
|
||||||
|
return DocumentAggregate.rehydrate({
|
||||||
|
id: entity.id,
|
||||||
|
tenantId: entity.tenantId,
|
||||||
|
title: entity.title,
|
||||||
|
description: entity.description,
|
||||||
|
originalFileName: entity.originalFileName,
|
||||||
|
mimeType: entity.mimeType,
|
||||||
|
sizeBytes: entity.sizeBytes,
|
||||||
|
storageProvider: entity.storageProvider,
|
||||||
|
storageKey: entity.storageKey,
|
||||||
|
checksumSha256: entity.checksumSha256,
|
||||||
|
uploadedByUserId: entity.uploadedByUserId,
|
||||||
|
metadata: entity.metadata ?? {},
|
||||||
|
tags: entity.tags ?? [],
|
||||||
|
links: (entity.links ?? []).map((link) => ({
|
||||||
|
id: link.id,
|
||||||
|
tenantId: link.tenantId,
|
||||||
|
documentId: link.documentId,
|
||||||
|
targetType: link.targetType === "user"
|
||||||
|
? "user"
|
||||||
|
: link.targetType === "subtask"
|
||||||
|
? "subtask"
|
||||||
|
: "task",
|
||||||
|
targetId: link.targetId,
|
||||||
|
createdAt: link.createdAt
|
||||||
|
})),
|
||||||
|
createdAt: entity.createdAt,
|
||||||
|
updatedAt: entity.updatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDocumentPersistence(document: DocumentAggregate): DocumentOrmEntity {
|
||||||
|
const primitives = document.toPrimitives();
|
||||||
|
const entity = new DocumentOrmEntity();
|
||||||
|
|
||||||
|
entity.id = primitives.id;
|
||||||
|
entity.tenantId = primitives.tenantId;
|
||||||
|
entity.title = primitives.title;
|
||||||
|
entity.description = primitives.description;
|
||||||
|
entity.originalFileName = primitives.originalFileName;
|
||||||
|
entity.mimeType = primitives.mimeType;
|
||||||
|
entity.sizeBytes = primitives.sizeBytes;
|
||||||
|
entity.storageProvider = primitives.storageProvider;
|
||||||
|
entity.storageKey = primitives.storageKey;
|
||||||
|
entity.checksumSha256 = primitives.checksumSha256;
|
||||||
|
entity.uploadedByUserId = primitives.uploadedByUserId;
|
||||||
|
entity.metadata = primitives.metadata;
|
||||||
|
entity.tags = primitives.tags;
|
||||||
|
entity.createdAt = primitives.createdAt;
|
||||||
|
entity.updatedAt = primitives.updatedAt;
|
||||||
|
entity.links = primitives.links.map((link) => {
|
||||||
|
const linkEntity = new DocumentLinkOrmEntity();
|
||||||
|
linkEntity.id = link.id;
|
||||||
|
linkEntity.tenantId = link.tenantId;
|
||||||
|
linkEntity.documentId = link.documentId;
|
||||||
|
linkEntity.targetType = link.targetType;
|
||||||
|
linkEntity.targetId = link.targetId;
|
||||||
|
linkEntity.createdAt = link.createdAt;
|
||||||
|
linkEntity.document = entity;
|
||||||
|
return linkEntity;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Repository as TypeOrmRepository } from "typeorm";
|
||||||
|
|
||||||
|
import { TenantDataSourceResolver } from "../../../../../../shared/infrastructure/persistence/typeorm/tenant-data-source.resolver";
|
||||||
|
import { DocumentAggregate } from "../../../../domain/aggregates/document.aggregate";
|
||||||
|
import { ListDocumentsFilter, DocumentRepository } from "../../../../domain/repositories/document.repository";
|
||||||
|
import { DocumentOrmEntity } from "../entities/document.orm-entity";
|
||||||
|
import { toDocumentDomain, toDocumentPersistence } from "../mappers/document-persistence.mapper";
|
||||||
|
|
||||||
|
export class TypeOrmDocumentRepository implements DocumentRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDataSourceResolver: TenantDataSourceResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async findById(id: string): Promise<DocumentAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { links: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return entity ? toDocumentDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByIdForTenant(
|
||||||
|
tenantId: string,
|
||||||
|
documentId: string
|
||||||
|
): Promise<DocumentAggregate | null> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const entity = await repository.findOne({
|
||||||
|
where: { id: documentId, tenantId },
|
||||||
|
relations: { links: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return entity ? toDocumentDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listForTenant(
|
||||||
|
tenantId: string,
|
||||||
|
filter?: ListDocumentsFilter
|
||||||
|
): Promise<DocumentAggregate[]> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
const queryBuilder = repository
|
||||||
|
.createQueryBuilder("document")
|
||||||
|
.leftJoinAndSelect("document.links", "link")
|
||||||
|
.where("document.tenant_id = :tenantId", { tenantId });
|
||||||
|
|
||||||
|
if (filter?.uploadedByUserId) {
|
||||||
|
queryBuilder.andWhere("document.uploaded_by_user_id = :uploadedByUserId", {
|
||||||
|
uploadedByUserId: filter.uploadedByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.targetType) {
|
||||||
|
queryBuilder.andWhere("link.target_type = :targetType", {
|
||||||
|
targetType: filter.targetType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.targetId) {
|
||||||
|
queryBuilder.andWhere("link.target_id = :targetId", {
|
||||||
|
targetId: filter.targetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queryBuilder
|
||||||
|
.orderBy("document.created_at", "DESC")
|
||||||
|
.addOrderBy("link.created_at", "ASC");
|
||||||
|
|
||||||
|
const entities = await queryBuilder.getMany();
|
||||||
|
return entities.map(toDocumentDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(aggregate: DocumentAggregate): Promise<void> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
await repository.save(toDocumentPersistence(aggregate));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteForTenant(tenantId: string, documentId: string): Promise<void> {
|
||||||
|
const repository = await this.getRepository();
|
||||||
|
await repository.delete({ id: documentId, tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRepository(): Promise<TypeOrmRepository<DocumentOrmEntity>> {
|
||||||
|
const dataSource = await this.tenantDataSourceResolver.getPlatformDataSource();
|
||||||
|
return dataSource.getRepository(DocumentOrmEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { AppConfig } from "../../../../shared/infrastructure/config/app-config";
|
||||||
|
import { DocumentStoragePort, DownloadedBinary, StoredDocumentDescriptor, UploadedBinary } from "../../application/ports/document-storage.port";
|
||||||
|
|
||||||
|
export class LocalDocumentStorageService implements DocumentStoragePort {
|
||||||
|
private readonly rootPath: string;
|
||||||
|
|
||||||
|
constructor(private readonly basePath: string) {
|
||||||
|
this.rootPath = path.resolve(process.cwd(), basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upload(
|
||||||
|
tenantId: string,
|
||||||
|
documentId: string,
|
||||||
|
file: UploadedBinary
|
||||||
|
): Promise<StoredDocumentDescriptor> {
|
||||||
|
const sanitizedFileName = sanitizeFileName(file.originalFileName);
|
||||||
|
const storageKey = path.posix.join(tenantId, documentId, sanitizedFileName);
|
||||||
|
const fullPath = this.resolveAbsolutePath(storageKey);
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
|
await fs.writeFile(fullPath, file.buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageProvider: "local",
|
||||||
|
storageKey,
|
||||||
|
checksumSha256: createHash("sha256").update(file.buffer).digest("hex")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async download(_tenantId: string, storageKey: string): Promise<DownloadedBinary> {
|
||||||
|
const fullPath = this.resolveAbsolutePath(storageKey);
|
||||||
|
const buffer = await fs.readFile(fullPath);
|
||||||
|
|
||||||
|
return { buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(_tenantId: string, storageKey: string): Promise<void> {
|
||||||
|
const fullPath = this.resolveAbsolutePath(storageKey);
|
||||||
|
await fs.rm(fullPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAbsolutePath(storageKey: string): string {
|
||||||
|
return path.resolve(this.rootPath, storageKey.split("/").join(path.sep));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(fileName: string): string {
|
||||||
|
const normalized = fileName.trim().replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||||
|
return normalized || "document.bin";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
|
||||||
|
import { DocumentStoragePort, DownloadedBinary, StoredDocumentDescriptor, UploadedBinary } from "../../application/ports/document-storage.port";
|
||||||
|
|
||||||
|
export interface S3StorageConfig {
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
endpoint?: string;
|
||||||
|
accessKeyId?: string;
|
||||||
|
secretAccessKey?: string;
|
||||||
|
forcePathStyle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class S3DocumentStorageService implements DocumentStoragePort {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
|
constructor(config: S3StorageConfig) {
|
||||||
|
this.bucket = config.bucket;
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: config.accessKeyId && config.secretAccessKey ? {
|
||||||
|
accessKeyId: config.accessKeyId,
|
||||||
|
secretAccessKey: config.secretAccessKey
|
||||||
|
} : undefined,
|
||||||
|
forcePathStyle: config.forcePathStyle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upload(tenantId: string, documentId: string, file: UploadedBinary): Promise<StoredDocumentDescriptor> {
|
||||||
|
const key = `${tenantId}/${documentId}-${file.originalFileName}`;
|
||||||
|
const checksum = createHash("sha256").update(file.buffer).digest("hex");
|
||||||
|
|
||||||
|
const upload = new Upload({
|
||||||
|
client: this.client,
|
||||||
|
params: {
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: file.mimeType,
|
||||||
|
Metadata: {
|
||||||
|
"original-name": file.originalFileName,
|
||||||
|
"tenant-id": tenantId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageProvider: "s3",
|
||||||
|
storageKey: key,
|
||||||
|
checksumSha256: checksum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async download(tenantId: string, storageKey: string): Promise<DownloadedBinary> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: storageKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.client.send(command);
|
||||||
|
|
||||||
|
if (!response.Body) {
|
||||||
|
throw new Error("Empty response body from S3");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.Body.transformToByteArray());
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(tenantId: string, storageKey: string): Promise<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: storageKey
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.send(command).catch((error) => {
|
||||||
|
// If file doesn't exist, we consider it deleted
|
||||||
|
if (error.name === "NoSuchKey") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type { Request as ExpressRequest } from "express";
|
||||||
|
import { Body, Controller, Delete, FormField, Get, Patch, Path, Post, Query, Request, Route, Security, SuccessResponse, Tags, UploadedFile } from "tsoa";
|
||||||
|
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { DownloadDocumentHandler, DownloadDocumentResult } from "../../../application/queries/download-document/download-document.handler";
|
||||||
|
import { GetDocumentByIdHandler } from "../../../application/queries/get-document-by-id/get-document-by-id.handler";
|
||||||
|
import { ListDocumentsHandler } from "../../../application/queries/list-documents/list-documents.handler";
|
||||||
|
import { UploadDocumentFileDto, UploadDocumentRequestDto } from "../../../application/commands/upload-document/upload-document.dto";
|
||||||
|
import { UploadDocumentHandler } from "../../../application/commands/upload-document/upload-document.handler";
|
||||||
|
import { UpdateDocumentRequestDto } from "../../../application/commands/update-document/update-document.dto";
|
||||||
|
import { UpdateDocumentHandler } from "../../../application/commands/update-document/update-document.handler";
|
||||||
|
import { DeleteDocumentHandler } from "../../../application/commands/delete-document/delete-document.handler";
|
||||||
|
import { DocumentResponseDto } from "../../../application/mappers/document-response.mapper";
|
||||||
|
import { normalizeOptionalString, parseLinkedIds, parseMetadata, parseTags } from "../../../application/mappers/document-input-parser";
|
||||||
|
import { DocumentLinkTargetType } from "../../../domain/entities/document-link.entity";
|
||||||
|
|
||||||
|
@Route("v1/documents")
|
||||||
|
@Tags("Documents")
|
||||||
|
export class DocumentsController extends Controller {
|
||||||
|
constructor(
|
||||||
|
private readonly uploadDocumentHandler: UploadDocumentHandler,
|
||||||
|
private readonly updateDocumentHandler: UpdateDocumentHandler,
|
||||||
|
private readonly deleteDocumentHandler: DeleteDocumentHandler,
|
||||||
|
private readonly getDocumentByIdHandler: GetDocumentByIdHandler,
|
||||||
|
private readonly listDocumentsHandler: ListDocumentsHandler,
|
||||||
|
private readonly downloadDocumentHandler: DownloadDocumentHandler
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload")
|
||||||
|
@Security("bearerAuth", ["documents:create"])
|
||||||
|
@SuccessResponse("201", "Created")
|
||||||
|
public async uploadDocument(
|
||||||
|
@Request() request: ExpressRequest,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@FormField() tenantId: string,
|
||||||
|
@FormField() title?: string,
|
||||||
|
@FormField() description?: string,
|
||||||
|
@FormField() metadata?: string,
|
||||||
|
@FormField() tags?: string,
|
||||||
|
@FormField() linkedTaskIds?: string,
|
||||||
|
@FormField() linkedSubTaskIds?: string,
|
||||||
|
@FormField() linkedUserIds?: string
|
||||||
|
): Promise<DocumentResponseDto> {
|
||||||
|
if (!file) {
|
||||||
|
throw new ApplicationError("A document file is required", 400, "DOCUMENT_FILE_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus(201);
|
||||||
|
return this.uploadDocumentHandler.execute({
|
||||||
|
tenantId,
|
||||||
|
title: normalizeOptionalString(title),
|
||||||
|
description: normalizeOptionalString(description),
|
||||||
|
metadata: parseMetadata(metadata),
|
||||||
|
tags: parseTags(tags),
|
||||||
|
linkedTaskIds: parseLinkedIds(linkedTaskIds),
|
||||||
|
linkedSubTaskIds: parseLinkedIds(linkedSubTaskIds),
|
||||||
|
linkedUserIds: parseLinkedIds(linkedUserIds),
|
||||||
|
uploadedByUserId: request.principal!.subject,
|
||||||
|
file: mapUploadedFile(file)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Security("bearerAuth", ["documents:read"])
|
||||||
|
public async listDocuments(
|
||||||
|
@Request() request: ExpressRequest,
|
||||||
|
@Query() uploadedByUserId?: string,
|
||||||
|
@Query() targetType?: DocumentLinkTargetType,
|
||||||
|
@Query() targetId?: string
|
||||||
|
): Promise<DocumentResponseDto[]> {
|
||||||
|
return this.listDocumentsHandler.execute({
|
||||||
|
tenantId: request.principal!.tenantId!,
|
||||||
|
uploadedByUserId,
|
||||||
|
targetType,
|
||||||
|
targetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("{documentId}")
|
||||||
|
@Security("bearerAuth", ["documents:read"])
|
||||||
|
public async getDocumentById(
|
||||||
|
@Request() request: ExpressRequest,
|
||||||
|
@Path() documentId: string
|
||||||
|
): Promise<DocumentResponseDto> {
|
||||||
|
return this.getDocumentByIdHandler.execute({
|
||||||
|
tenantId: request.principal!.tenantId!,
|
||||||
|
documentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("{documentId}/download")
|
||||||
|
@Security("bearerAuth", ["documents:read"])
|
||||||
|
public async downloadDocument(
|
||||||
|
@Request() request: ExpressRequest,
|
||||||
|
@Path() documentId: string
|
||||||
|
): Promise<DownloadDocumentResult> {
|
||||||
|
return this.downloadDocumentHandler.execute({
|
||||||
|
tenantId: request.principal!.tenantId!,
|
||||||
|
documentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("{documentId}")
|
||||||
|
@Security("bearerAuth", ["documents:update"])
|
||||||
|
public async updateDocument(
|
||||||
|
@Path() documentId: string,
|
||||||
|
@Body() requestBody: UpdateDocumentRequestDto
|
||||||
|
): Promise<DocumentResponseDto> {
|
||||||
|
return this.updateDocumentHandler.execute({
|
||||||
|
tenantId: requestBody.tenantId,
|
||||||
|
documentId,
|
||||||
|
title: normalizeOptionalString(requestBody.title),
|
||||||
|
description: requestBody.description === null
|
||||||
|
? null
|
||||||
|
: normalizeOptionalString(requestBody.description),
|
||||||
|
metadata: parseMetadata(requestBody.metadata),
|
||||||
|
tags: parseTags(requestBody.tags),
|
||||||
|
linkedTaskIds: parseLinkedIds(requestBody.linkedTaskIds),
|
||||||
|
linkedSubTaskIds: parseLinkedIds(requestBody.linkedSubTaskIds),
|
||||||
|
linkedUserIds: parseLinkedIds(requestBody.linkedUserIds)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("{documentId}")
|
||||||
|
@Security("bearerAuth", ["documents:delete"])
|
||||||
|
@SuccessResponse("204", "Deleted")
|
||||||
|
public async deleteDocument(
|
||||||
|
@Request() request: ExpressRequest,
|
||||||
|
@Path() documentId: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.setStatus(204);
|
||||||
|
await this.deleteDocumentHandler.execute({
|
||||||
|
tenantId: request.principal!.tenantId!,
|
||||||
|
documentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUploadedFile(file: Express.Multer.File): UploadDocumentFileDto {
|
||||||
|
return {
|
||||||
|
buffer: file.buffer,
|
||||||
|
originalFileName: file.originalname,
|
||||||
|
mimeType: file.mimetype || "application/octet-stream",
|
||||||
|
sizeBytes: file.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface AssignRolesCommand {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
roleCodes: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface AssignRolesRequestDto {
|
||||||
|
roleCodes: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { RoleRepository } from "../../../domain/repositories/role.repository";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { AssignRolesCommand } from "./assign-roles.command";
|
||||||
|
|
||||||
|
export class AssignRolesHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly roleRepository: RoleRepository,
|
||||||
|
private readonly clock: ClockPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: AssignRolesCommand): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findByIdForTenant(command.tenantId, command.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("User not found", 404, "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.roleCodes.length === 0) {
|
||||||
|
throw new ApplicationError("At least one role is required", 422, "ROLE_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = await this.roleRepository.findByCodes(command.tenantId, command.roleCodes);
|
||||||
|
|
||||||
|
if (roles.length !== command.roleCodes.length) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"One or more roles do not exist",
|
||||||
|
422,
|
||||||
|
"ROLE_NOT_FOUND"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.assignRoles(roles, this.clock.now());
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export interface CreateUserCommand {
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
roleCodes?: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
|
||||||
|
export class CreateUserRequestDto {
|
||||||
|
tenantId!: string;
|
||||||
|
email!: string;
|
||||||
|
firstName!: string;
|
||||||
|
lastName!: string;
|
||||||
|
password!: string;
|
||||||
|
roleCodes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UserResponseDto };
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { PasswordHasherPort } from "../../../../../shared/application/ports/password-hasher.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { TenantRepository } from "../../../../tenancy/domain/repositories/tenant.repository";
|
||||||
|
import { UserAggregate } from "../../../domain/aggregates/user.aggregate";
|
||||||
|
import { RoleRepository } from "../../../domain/repositories/role.repository";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { CreateUserCommand } from "./create-user.command";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../../billing/domain/repositories/tenant-subscription.repository";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../../billing/domain/repositories/subscription-plan.repository";
|
||||||
|
|
||||||
|
export class CreateUserHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly roleRepository: RoleRepository,
|
||||||
|
private readonly tenantRepository: TenantRepository,
|
||||||
|
private readonly subscriptionRepository: TenantSubscriptionRepository,
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository,
|
||||||
|
private readonly passwordHasher: PasswordHasherPort,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: CreateUserCommand): Promise<UserResponseDto> {
|
||||||
|
if (command.password.length < 12) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"Password must be at least 12 characters long",
|
||||||
|
422,
|
||||||
|
"WEAK_PASSWORD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await this.tenantRepository.findById(command.tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new ApplicationError("Tenant not found", 404, "TENANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant.status !== "active") {
|
||||||
|
throw new ApplicationError("Tenant is inactive", 403, "TENANT_INACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription Limit Check
|
||||||
|
const subscription = await this.subscriptionRepository.findByTenantId(command.tenantId);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new ApplicationError("Subscription not found", 500, "SUBSCRIPTION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await this.planRepository.findById(subscription.planId);
|
||||||
|
if (!plan) {
|
||||||
|
throw new ApplicationError("Plan not found", 500, "BILLING_CONFIGURATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.maxUsers !== -1) {
|
||||||
|
const currentUserCount = await this.userRepository.countByTenantId(command.tenantId);
|
||||||
|
if (currentUserCount >= plan.maxUsers) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
`User limit reached for your ${plan.name} plan (${plan.maxUsers} users). Please upgrade to add more users.`,
|
||||||
|
403,
|
||||||
|
"USER_LIMIT_REACHED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await this.userRepository.findByEmail(
|
||||||
|
command.tenantId,
|
||||||
|
command.email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"A user with this email already exists in the tenant",
|
||||||
|
409,
|
||||||
|
"USER_EMAIL_CONFLICT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCodes =
|
||||||
|
command.roleCodes && command.roleCodes.length > 0
|
||||||
|
? command.roleCodes
|
||||||
|
: ["tenant_user"];
|
||||||
|
const roles = await this.roleRepository.findByCodes(command.tenantId, roleCodes);
|
||||||
|
|
||||||
|
if (roles.length !== roleCodes.length) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"One or more roles do not exist",
|
||||||
|
422,
|
||||||
|
"ROLE_NOT_FOUND"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = UserAggregate.register({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
email: command.email,
|
||||||
|
firstName: command.firstName,
|
||||||
|
lastName: command.lastName,
|
||||||
|
passwordHash: await this.passwordHasher.hash(command.password),
|
||||||
|
roles,
|
||||||
|
now: this.clock.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface InviteUserCommand {
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleCodes?: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface InviteUserRequestDto {
|
||||||
|
/**
|
||||||
|
* @format uuid
|
||||||
|
*/
|
||||||
|
tenantId: string;
|
||||||
|
/**
|
||||||
|
* @format email
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleCodes?: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { TenantRepository } from "../../../../tenancy/domain/repositories/tenant.repository";
|
||||||
|
import { UserAggregate } from "../../../domain/aggregates/user.aggregate";
|
||||||
|
import { RoleRepository } from "../../../domain/repositories/role.repository";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { EmailPort } from "../../ports/email.port";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { InviteUserCommand } from "./invite-user.command";
|
||||||
|
import { TenantSubscriptionRepository } from "../../../../billing/domain/repositories/tenant-subscription.repository";
|
||||||
|
import { SubscriptionPlanRepository } from "../../../../billing/domain/repositories/subscription-plan.repository";
|
||||||
|
|
||||||
|
export class InviteUserHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly roleRepository: RoleRepository,
|
||||||
|
private readonly tenantRepository: TenantRepository,
|
||||||
|
private readonly subscriptionRepository: TenantSubscriptionRepository,
|
||||||
|
private readonly planRepository: SubscriptionPlanRepository,
|
||||||
|
private readonly emailSender: EmailPort,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: InviteUserCommand): Promise<UserResponseDto> {
|
||||||
|
const tenant = await this.tenantRepository.findById(command.tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new ApplicationError("Tenant not found", 404, "TENANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant.status !== "active") {
|
||||||
|
throw new ApplicationError("Tenant is inactive", 403, "TENANT_INACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription Limit Check
|
||||||
|
const subscription = await this.subscriptionRepository.findByTenantId(command.tenantId);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new ApplicationError("Subscription not found", 500, "SUBSCRIPTION_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await this.planRepository.findById(subscription.planId);
|
||||||
|
if (!plan) {
|
||||||
|
throw new ApplicationError("Plan not found", 500, "BILLING_CONFIGURATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.maxUsers !== -1) {
|
||||||
|
const currentUserCount = await this.userRepository.countByTenantId(command.tenantId);
|
||||||
|
if (currentUserCount >= plan.maxUsers) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
`User limit reached for your ${plan.name} plan (${plan.maxUsers} users). Please upgrade to invite more users.`,
|
||||||
|
403,
|
||||||
|
"USER_LIMIT_REACHED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await this.userRepository.findByEmail(
|
||||||
|
command.tenantId,
|
||||||
|
command.email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"A user with this email already exists in the tenant",
|
||||||
|
409,
|
||||||
|
"USER_EMAIL_CONFLICT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCodes =
|
||||||
|
command.roleCodes && command.roleCodes.length > 0
|
||||||
|
? command.roleCodes
|
||||||
|
: ["tenant_user"];
|
||||||
|
const roles = await this.roleRepository.findByCodes(command.tenantId, roleCodes);
|
||||||
|
|
||||||
|
if (roles.length !== roleCodes.length) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"One or more roles do not exist",
|
||||||
|
422,
|
||||||
|
"ROLE_NOT_FOUND"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = UserAggregate.invite({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
email: command.email,
|
||||||
|
firstName: command.firstName,
|
||||||
|
lastName: command.lastName,
|
||||||
|
roles,
|
||||||
|
now: this.clock.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// Send invitation email
|
||||||
|
await this.emailSender.send({
|
||||||
|
to: user.email,
|
||||||
|
subject: `Invitation to join ${tenant.name}`,
|
||||||
|
body: `Hello ${user.firstName},\n\nYou have been invited to join ${tenant.name} on Gem Track.\n\nPlease follow the link below to set up your account.`
|
||||||
|
}).catch((error) => {
|
||||||
|
// We log the error but don't fail the command as the user is already created
|
||||||
|
console.error("Failed to send invitation email", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface LoginCommand {
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export class LoginRequestDto {
|
||||||
|
tenantId!: string;
|
||||||
|
email!: string;
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { PasswordHasherPort } from "../../../../../shared/application/ports/password-hasher.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { TenantRepository } from "../../../../tenancy/domain/repositories/tenant.repository";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse } from "../../mappers/user-response.mapper";
|
||||||
|
import { AccessTokenService } from "../../services/access-token.service";
|
||||||
|
import { LoginCommand } from "./login.command";
|
||||||
|
import { AuthResponseDto } from "../register-user/register-user.dto";
|
||||||
|
|
||||||
|
export class LoginHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly tenantRepository: TenantRepository,
|
||||||
|
private readonly passwordHasher: PasswordHasherPort,
|
||||||
|
private readonly accessTokenService: AccessTokenService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: LoginCommand): Promise<AuthResponseDto> {
|
||||||
|
const tenant = await this.tenantRepository.findById(command.tenantId);
|
||||||
|
|
||||||
|
if (!tenant || tenant.status !== "active") {
|
||||||
|
throw new ApplicationError("Invalid credentials", 401, "INVALID_CREDENTIALS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findByEmail(
|
||||||
|
command.tenantId,
|
||||||
|
command.email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("Invalid credentials", 401, "INVALID_CREDENTIALS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatches = await this.passwordHasher.compare(
|
||||||
|
command.password,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatches) {
|
||||||
|
throw new ApplicationError("Invalid credentials", 401, "INVALID_CREDENTIALS");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== "active") {
|
||||||
|
throw new ApplicationError("User account is inactive", 403, "USER_INACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: this.accessTokenService.issueFor(user),
|
||||||
|
user: toUserResponse(user)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface RegisterUserCommand {
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
|
||||||
|
export class RegisterUserRequestDto {
|
||||||
|
tenantId!: string;
|
||||||
|
email!: string;
|
||||||
|
firstName!: string;
|
||||||
|
lastName!: string;
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthResponseDto {
|
||||||
|
accessToken!: string;
|
||||||
|
user!: UserResponseDto;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { IdGeneratorPort } from "../../../../../shared/application/ports/id-generator.port";
|
||||||
|
import { PasswordHasherPort } from "../../../../../shared/application/ports/password-hasher.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { TenantRepository } from "../../../../tenancy/domain/repositories/tenant.repository";
|
||||||
|
import { RoleRepository } from "../../../domain/repositories/role.repository";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { UserAggregate } from "../../../domain/aggregates/user.aggregate";
|
||||||
|
import { toUserResponse } from "../../mappers/user-response.mapper";
|
||||||
|
import { RegisterUserCommand } from "./register-user.command";
|
||||||
|
import { AuthResponseDto } from "./register-user.dto";
|
||||||
|
import { AccessTokenService } from "../../services/access-token.service";
|
||||||
|
|
||||||
|
export class RegisterUserHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly roleRepository: RoleRepository,
|
||||||
|
private readonly tenantRepository: TenantRepository,
|
||||||
|
private readonly passwordHasher: PasswordHasherPort,
|
||||||
|
private readonly clock: ClockPort,
|
||||||
|
private readonly idGenerator: IdGeneratorPort,
|
||||||
|
private readonly accessTokenService: AccessTokenService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: RegisterUserCommand): Promise<AuthResponseDto> {
|
||||||
|
if (command.password.length < 12) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"Password must be at least 12 characters long",
|
||||||
|
422,
|
||||||
|
"WEAK_PASSWORD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await this.tenantRepository.findById(command.tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new ApplicationError("Tenant not found", 404, "TENANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant.status !== "active") {
|
||||||
|
throw new ApplicationError("Tenant is inactive", 403, "TENANT_INACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await this.userRepository.findByEmail(
|
||||||
|
command.tenantId,
|
||||||
|
command.email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"A user with this email already exists in the tenant",
|
||||||
|
409,
|
||||||
|
"USER_EMAIL_CONFLICT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = await this.roleRepository.findByCodes(command.tenantId, ["tenant_admin"]);
|
||||||
|
|
||||||
|
if (roles.length !== 1) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
"The default tenant_admin role is not configured",
|
||||||
|
500,
|
||||||
|
"DEFAULT_ROLE_MISSING"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = UserAggregate.register({
|
||||||
|
id: this.idGenerator.generate(),
|
||||||
|
tenantId: command.tenantId,
|
||||||
|
email: command.email,
|
||||||
|
firstName: command.firstName,
|
||||||
|
lastName: command.lastName,
|
||||||
|
passwordHash: await this.passwordHasher.hash(command.password),
|
||||||
|
roles,
|
||||||
|
now: this.clock.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: this.accessTokenService.issueFor(user),
|
||||||
|
user: toUserResponse(user)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { UserStatus } from "../../../domain/aggregates/user.aggregate";
|
||||||
|
|
||||||
|
export interface UpdateUserStatusCommand {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { UserStatus } from "../../../domain/aggregates/user.aggregate";
|
||||||
|
|
||||||
|
export interface UpdateUserStatusRequestDto {
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ClockPort } from "../../../../../shared/application/ports/clock.port";
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { UpdateUserStatusCommand } from "./update-user-status.command";
|
||||||
|
|
||||||
|
export class UpdateUserStatusHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly clock: ClockPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async execute(command: UpdateUserStatusCommand): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findByIdForTenant(command.tenantId, command.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("User not found", 404, "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.clock.now();
|
||||||
|
|
||||||
|
switch (command.status) {
|
||||||
|
case "active":
|
||||||
|
user.activate(now);
|
||||||
|
break;
|
||||||
|
case "suspended":
|
||||||
|
user.deactivate(now);
|
||||||
|
break;
|
||||||
|
case "pending_invitation":
|
||||||
|
// This is usually set only during invitation, but if allowed:
|
||||||
|
throw new ApplicationError("Cannot manually set status to pending_invitation", 422, "INVALID_STATUS_TRANSITION");
|
||||||
|
default:
|
||||||
|
throw new ApplicationError(`Invalid status: ${command.status}`, 422, "INVALID_STATUS");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { UserAggregate } from "../../domain/aggregates/user.aggregate";
|
||||||
|
|
||||||
|
export class UserResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
email!: string;
|
||||||
|
firstName!: string;
|
||||||
|
lastName!: string;
|
||||||
|
status!: "active" | "suspended" | "pending_invitation";
|
||||||
|
roles!: string[];
|
||||||
|
permissions!: string[];
|
||||||
|
createdAt!: string;
|
||||||
|
updatedAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserResponse(user: UserAggregate): UserResponseDto {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
status: user.status,
|
||||||
|
roles: user.roles.map((role) => role.code),
|
||||||
|
permissions: user.permissions,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
updatedAt: user.updatedAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface SendEmailProps {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailPort {
|
||||||
|
send(props: SendEmailProps): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { GetCurrentUserQuery } from "./get-current-user.query";
|
||||||
|
|
||||||
|
export class GetCurrentUserHandler {
|
||||||
|
constructor(private readonly userRepository: UserRepository) {}
|
||||||
|
|
||||||
|
public async execute(query: GetCurrentUserQuery): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findByIdForTenant(
|
||||||
|
query.tenantId,
|
||||||
|
query.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("User not found", 404, "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface GetCurrentUserQuery {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ApplicationError } from "../../../../../shared/domain/errors/application-error";
|
||||||
|
import { UserRepository } from "../../../domain/repositories/user.repository";
|
||||||
|
import { toUserResponse, UserResponseDto } from "../../mappers/user-response.mapper";
|
||||||
|
import { GetUserByIdQuery } from "./get-user-by-id.query";
|
||||||
|
|
||||||
|
export class GetUserByIdHandler {
|
||||||
|
constructor(private readonly userRepository: UserRepository) {}
|
||||||
|
|
||||||
|
public async execute(query: GetUserByIdQuery): Promise<UserResponseDto> {
|
||||||
|
const user = await this.userRepository.findByIdForTenant(
|
||||||
|
query.tenantId,
|
||||||
|
query.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApplicationError("User not found", 404, "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toUserResponse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface GetUserByIdQuery {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { JwtService } from "../../../../shared/infrastructure/security/jwt.service";
|
||||||
|
import { UserAggregate } from "../../domain/aggregates/user.aggregate";
|
||||||
|
import { AuthPrincipalFactory } from "./auth-principal.factory";
|
||||||
|
|
||||||
|
export class AccessTokenService {
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly authPrincipalFactory: AuthPrincipalFactory
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public issueFor(user: UserAggregate): string {
|
||||||
|
return this.jwtService.sign(this.authPrincipalFactory.create(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { AuthPrincipal } from "../../../../shared/domain/types/request-context";
|
||||||
|
import { UserAggregate } from "../../domain/aggregates/user.aggregate";
|
||||||
|
|
||||||
|
export class AuthPrincipalFactory {
|
||||||
|
public create(user: UserAggregate): AuthPrincipal {
|
||||||
|
return {
|
||||||
|
subject: user.id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.roles.map((role) => role.code),
|
||||||
|
permissions: user.permissions,
|
||||||
|
tenantId: user.tenantId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { AggregateRoot } from "../../../../shared/domain/base/aggregate-root";
|
||||||
|
import { DomainError } from "../../../../shared/domain/errors/domain-error";
|
||||||
|
import { RoleEntity, RolePrimitives } from "../entities/role.entity";
|
||||||
|
import { Email } from "../value-objects/email.vo";
|
||||||
|
import { PasswordHash } from "../value-objects/password-hash.vo";
|
||||||
|
|
||||||
|
export type UserStatus = "active" | "suspended" | "pending_invitation";
|
||||||
|
|
||||||
|
export interface UserPrimitives {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
status: UserStatus;
|
||||||
|
roles: RolePrimitives[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterUserProps {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
roles: RoleEntity[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InviteUserProps {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roles: RoleEntity[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserAggregate extends AggregateRoot<string> {
|
||||||
|
private readonly rolesState: RoleEntity[];
|
||||||
|
private constructor(private readonly state: UserPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
this.rolesState = state.roles.map((role) => new RoleEntity(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static register(props: RegisterUserProps): UserAggregate {
|
||||||
|
const firstName = props.firstName.trim();
|
||||||
|
const lastName = props.lastName.trim();
|
||||||
|
|
||||||
|
if (firstName.length < 2) {
|
||||||
|
throw new DomainError("First name must be at least 2 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName.length < 2) {
|
||||||
|
throw new DomainError("Last name must be at least 2 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.tenantId.trim()) {
|
||||||
|
throw new DomainError("Tenant id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = new UserAggregate({
|
||||||
|
id: props.id,
|
||||||
|
tenantId: props.tenantId.trim(),
|
||||||
|
email: Email.create(props.email).value,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
passwordHash: PasswordHash.create(props.passwordHash).value,
|
||||||
|
status: "active",
|
||||||
|
roles: props.roles.map((role) => role.toPrimitives()),
|
||||||
|
createdAt: props.now,
|
||||||
|
updatedAt: props.now
|
||||||
|
});
|
||||||
|
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static invite(props: InviteUserProps): UserAggregate {
|
||||||
|
const firstName = props.firstName.trim();
|
||||||
|
const lastName = props.lastName.trim();
|
||||||
|
|
||||||
|
if (firstName.length < 2) {
|
||||||
|
throw new DomainError("First name must be at least 2 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName.length < 2) {
|
||||||
|
throw new DomainError("Last name must be at least 2 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.tenantId.trim()) {
|
||||||
|
throw new DomainError("Tenant id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserAggregate({
|
||||||
|
id: props.id,
|
||||||
|
tenantId: props.tenantId.trim(),
|
||||||
|
email: Email.create(props.email).value,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
passwordHash: "", // No password yet for invited users
|
||||||
|
status: "pending_invitation",
|
||||||
|
roles: props.roles.map((role) => role.toPrimitives()),
|
||||||
|
createdAt: props.now,
|
||||||
|
updatedAt: props.now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static rehydrate(primitives: UserPrimitives): UserAggregate {
|
||||||
|
return new UserAggregate({
|
||||||
|
...primitives,
|
||||||
|
email: Email.create(primitives.email).value,
|
||||||
|
passwordHash: PasswordHash.create(primitives.passwordHash).value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public assignRoles(roles: RoleEntity[], now: Date): void {
|
||||||
|
if (roles.length === 0) {
|
||||||
|
throw new DomainError("At least one role is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rolesState.splice(0, this.rolesState.length, ...roles);
|
||||||
|
this.state.roles = roles.map((role) => role.toPrimitives());
|
||||||
|
this.state.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public activate(now: Date): void {
|
||||||
|
if (this.state.status === "active") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.status = "active";
|
||||||
|
this.state.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivate(now: Date): void {
|
||||||
|
if (this.state.status === "suspended") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.status = "suspended";
|
||||||
|
this.state.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasPermission(permissionCode: string): boolean {
|
||||||
|
return this.permissions.includes(permissionCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get tenantId(): string {
|
||||||
|
return this.state.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get email(): string {
|
||||||
|
return this.state.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get firstName(): string {
|
||||||
|
return this.state.firstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get lastName(): string {
|
||||||
|
return this.state.lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get passwordHash(): string {
|
||||||
|
return this.state.passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get status(): UserStatus {
|
||||||
|
return this.state.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get roles(): RoleEntity[] {
|
||||||
|
return [...this.rolesState];
|
||||||
|
}
|
||||||
|
|
||||||
|
public get permissions(): string[] {
|
||||||
|
return [...new Set(this.rolesState.flatMap((role) => role.permissions.map((permission) => permission.code)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public get createdAt(): Date {
|
||||||
|
return this.state.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get updatedAt(): Date {
|
||||||
|
return this.state.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): UserPrimitives {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
roles: this.rolesState.map((role) => role.toPrimitives())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Entity } from "../../../../shared/domain/base/entity";
|
||||||
|
|
||||||
|
export interface PermissionPrimitives {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PermissionEntity extends Entity<string> {
|
||||||
|
constructor(private readonly state: PermissionPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get code(): string {
|
||||||
|
return this.state.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name(): string {
|
||||||
|
return this.state.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get description(): string | undefined {
|
||||||
|
return this.state.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): PermissionPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Entity } from "../../../../shared/domain/base/entity";
|
||||||
|
|
||||||
|
export interface RolePermissionPrimitives {
|
||||||
|
id: string;
|
||||||
|
roleId: string;
|
||||||
|
permissionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RolePermissionEntity extends Entity<string> {
|
||||||
|
constructor(private readonly state: RolePermissionPrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): RolePermissionPrimitives {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Entity } from "../../../../shared/domain/base/entity";
|
||||||
|
import { PermissionEntity, PermissionPrimitives } from "./permission.entity";
|
||||||
|
|
||||||
|
export interface RolePrimitives {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissions: PermissionPrimitives[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleEntity extends Entity<string> {
|
||||||
|
private readonly permissionsState: PermissionEntity[];
|
||||||
|
|
||||||
|
constructor(private readonly state: RolePrimitives) {
|
||||||
|
super(state.id);
|
||||||
|
this.permissionsState = state.permissions.map(
|
||||||
|
(permission) => new PermissionEntity(permission)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get code(): string {
|
||||||
|
return this.state.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name(): string {
|
||||||
|
return this.state.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get description(): string | undefined {
|
||||||
|
return this.state.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get permissions(): PermissionEntity[] {
|
||||||
|
return [...this.permissionsState];
|
||||||
|
}
|
||||||
|
|
||||||
|
public toPrimitives(): RolePrimitives {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
permissions: this.permissionsState.map((permission) =>
|
||||||
|
permission.toPrimitives()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue