Initialize project and add containerization

This commit is contained in:
Vikalp 2026-04-09 12:17:58 +05:30
commit cb34dd7350
204 changed files with 17651 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
Dockerfile
docker-compose.yml
.dockerignore
README.md
docs
errors.txt

21
.env.example Normal file
View File

@ -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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
coverage/

73
API_DOCUMENTATION.md Normal file
View File

@ -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>
```

43
Dockerfile Normal file
View File

@ -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"]

98
README.md Normal file
View File

@ -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.

49
docker-compose.yml Normal file
View File

@ -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:

51
docs/architecture.md Normal file
View File

@ -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.

BIN
errors.txt Normal file

Binary file not shown.

49
fix-paths.js Normal file
View File

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

6400
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

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

52
src/app.ts Normal file
View File

@ -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;
}

View File

@ -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;
}

41
src/main.ts Normal file
View File

@ -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();

View File

@ -0,0 +1,4 @@
export interface ChangeSubscriptionPlanCommand {
tenantId: string;
planCode: string;
}

View File

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

View File

@ -0,0 +1,3 @@
export interface InitializeSubscriptionCommand {
tenantId: string;
}

View File

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

View File

@ -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>;
}

View File

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

View File

@ -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;
}

View File

@ -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
};
}
}

View File

@ -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;
}

View File

@ -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 };
}
}

View File

@ -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 };
}
}

View File

@ -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; }
}

View File

@ -0,0 +1,6 @@
import { BillingRecordEntity } from "../entities/billing-record.entity";
export interface BillingRecordRepository {
save(record: BillingRecordEntity): Promise<void>;
findByTenantId(tenantId: string): Promise<BillingRecordEntity[]>;
}

View File

@ -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[]>;
}

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}`);
}
}

View File

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

View File

@ -0,0 +1,4 @@
export interface DeleteDocumentCommand {
tenantId: string;
documentId: string;
}

View File

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

View File

@ -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[];
}

View File

@ -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 };

View File

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

View File

@ -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;
};
}

View File

@ -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 };

View File

@ -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;
}
}
}

View File

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

View File

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

View File

@ -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>;
}

View File

@ -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
};
}
}

View File

@ -0,0 +1,4 @@
export interface DownloadDocumentQuery {
tenantId: string;
documentId: string;
}

View File

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

View File

@ -0,0 +1,4 @@
export interface GetDocumentByIdQuery {
tenantId: string;
documentId: string;
}

View File

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

View File

@ -0,0 +1,8 @@
import { DocumentLinkTargetType } from "../../../domain/entities/document-link.entity";
export interface ListDocumentsQuery {
tenantId: string;
uploadedByUserId?: string;
targetType?: DocumentLinkTargetType;
targetId?: string;
}

View File

@ -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))];
}

View File

@ -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))];
}

View File

@ -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 };
}
}

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

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

View File

@ -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";
}

View File

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

View File

@ -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
};
}

View File

@ -0,0 +1,5 @@
export interface AssignRolesCommand {
tenantId: string;
userId: string;
roleCodes: string[];
}

View File

@ -0,0 +1,3 @@
export interface AssignRolesRequestDto {
roleCodes: string[];
}

View File

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

View File

@ -0,0 +1,8 @@
export interface CreateUserCommand {
tenantId: string;
email: string;
firstName: string;
lastName: string;
password: string;
roleCodes?: string[];
}

View File

@ -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 };

View File

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

View File

@ -0,0 +1,7 @@
export interface InviteUserCommand {
tenantId: string;
email: string;
firstName: string;
lastName: string;
roleCodes?: string[];
}

View File

@ -0,0 +1,13 @@
export interface InviteUserRequestDto {
/**
* @format uuid
*/
tenantId: string;
/**
* @format email
*/
email: string;
firstName: string;
lastName: string;
roleCodes?: string[];
}

View File

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

View File

@ -0,0 +1,5 @@
export interface LoginCommand {
tenantId: string;
email: string;
password: string;
}

View File

@ -0,0 +1,5 @@
export class LoginRequestDto {
tenantId!: string;
email!: string;
password!: string;
}

View File

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

View File

@ -0,0 +1,7 @@
export interface RegisterUserCommand {
tenantId: string;
email: string;
firstName: string;
lastName: string;
password: string;
}

View File

@ -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;
}

View File

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

View File

@ -0,0 +1,7 @@
import { UserStatus } from "../../../domain/aggregates/user.aggregate";
export interface UpdateUserStatusCommand {
tenantId: string;
userId: string;
status: UserStatus;
}

View File

@ -0,0 +1,5 @@
import { UserStatus } from "../../../domain/aggregates/user.aggregate";
export interface UpdateUserStatusRequestDto {
status: UserStatus;
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
export interface SendEmailProps {
to: string;
subject: string;
body: string;
}
export interface EmailPort {
send(props: SendEmailProps): Promise<void>;
}

View File

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

View File

@ -0,0 +1,4 @@
export interface GetCurrentUserQuery {
tenantId: string;
userId: string;
}

View File

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

View File

@ -0,0 +1,4 @@
export interface GetUserByIdQuery {
tenantId: string;
userId: string;
}

View File

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

View File

@ -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
};
}
}

View File

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

View File

@ -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 };
}
}

View File

@ -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 };
}
}

View File

@ -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