website refactor
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
This document defines **Blockers** as UX-only prevention mechanisms in the website.
|
||||
|
||||
**IMPORTANT**: Blockers are **optional UX helpers**. They are NOT enforced by ESLint rules and do not belong in the strict architecture contract.
|
||||
|
||||
Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
|
||||
|
||||
## 1) Definition
|
||||
@@ -10,42 +12,64 @@ A Blocker is a website mechanism that prevents an action from being executed.
|
||||
|
||||
Blockers exist solely to improve UX and reduce unnecessary requests.
|
||||
|
||||
Blockers are not security.
|
||||
**Blockers are not security.** They are best-effort helpers that can be bypassed.
|
||||
|
||||
## 2) Responsibilities
|
||||
## 2) Purpose
|
||||
|
||||
Blockers MAY:
|
||||
Use Blockers to:
|
||||
- Prevent multiple form submissions
|
||||
- Debounce rapid button clicks
|
||||
- Temporarily disable actions during loading
|
||||
- Show/hide UI elements based on state
|
||||
|
||||
- prevent multiple submissions
|
||||
- disable actions temporarily
|
||||
- debounce or throttle interactions
|
||||
- hide or disable UI elements
|
||||
- prevent navigation under certain conditions
|
||||
**Do NOT use Blockers for:**
|
||||
- Authorization checks
|
||||
- Security enforcement
|
||||
- Permanent access control
|
||||
|
||||
Blockers MUST:
|
||||
## 3) Placement
|
||||
|
||||
- be reversible
|
||||
- be local to the website
|
||||
- be treated as best-effort helpers
|
||||
Since Blockers are UX-only, they belong in:
|
||||
- `apps/website/components/**` (component-specific blockers)
|
||||
- `apps/website/hooks/**` (shared blocker hooks)
|
||||
- `apps/website/utils/**` (blocker utilities)
|
||||
|
||||
## 3) Restrictions
|
||||
**NOT in `lib/`** - `lib/` is for business logic and architecture contracts.
|
||||
|
||||
Blockers MUST NOT:
|
||||
## 4) Example
|
||||
|
||||
- enforce security
|
||||
- claim authorization
|
||||
- block access permanently
|
||||
- replace API Guards
|
||||
- make assumptions about backend state
|
||||
```typescript
|
||||
// ✅ OK: Component-level blocker
|
||||
export function useSubmitBlocker() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
block: () => setIsSubmitting(true),
|
||||
release: () => setIsSubmitting(false),
|
||||
};
|
||||
}
|
||||
|
||||
## 4) Common Blockers
|
||||
// Usage
|
||||
const blocker = useSubmitBlocker();
|
||||
|
||||
- SubmitBlocker
|
||||
- ThrottleBlocker
|
||||
- NavigationBlocker
|
||||
- FeatureBlocker
|
||||
async function handleSubmit() {
|
||||
if (blocker.isSubmitting) return;
|
||||
|
||||
blocker.block();
|
||||
await submitForm();
|
||||
blocker.release();
|
||||
}
|
||||
```
|
||||
|
||||
## 5) Canonical placement
|
||||
## 5) Key Principle
|
||||
|
||||
- `apps/website/lib/blockers/**`
|
||||
**Blockers are optional.** The backend must never rely on them.
|
||||
|
||||
If a blocker prevents a submission, the backend should still:
|
||||
- Validate the request
|
||||
- Return appropriate errors
|
||||
- Handle duplicate submissions gracefully
|
||||
|
||||
This is why Blockers don't need ESLint enforcement - they're just UX sugar.
|
||||
|
||||
|
||||
@@ -1,108 +1,64 @@
|
||||
Command Models
|
||||
|
||||
This document defines Command Models as a first-class concept in the frontend architecture.
|
||||
Command Models are UX-only write models used to collect, validate, and prepare user input
|
||||
before it is sent to the backend as a Command DTO.
|
||||
This document defines Command Models as a concept for frontend form handling.
|
||||
|
||||
Command Models are not View Models and not Domain Models.
|
||||
**IMPORTANT**: Command Models are **optional UX helpers**. They are NOT enforced by ESLint rules and do not belong in the strict architecture contract.
|
||||
|
||||
⸻
|
||||
## 1) Definition
|
||||
|
||||
Purpose
|
||||
Command Models (also called Form Models) are UX-only write models used to:
|
||||
- Collect user input
|
||||
- Track form state (dirty, touched, submitting)
|
||||
- Perform basic UX validation
|
||||
- Build Command DTOs for submission
|
||||
|
||||
A Form Model answers the question:
|
||||
**Command Models are NOT:**
|
||||
- Domain models
|
||||
- View models
|
||||
- Security boundaries
|
||||
- Required for the architecture
|
||||
|
||||
“What does the UI need in order to safely submit user input?”
|
||||
## 2) Purpose
|
||||
|
||||
Command Models exist to:
|
||||
• centralize form state
|
||||
• reduce logic inside components
|
||||
• provide consistent client-side validation
|
||||
• build Command DTOs explicitly
|
||||
Use Command Models when:
|
||||
- Forms have complex state management
|
||||
- Multiple fields need validation
|
||||
- You want to centralize form logic
|
||||
- Building DTOs is non-trivial
|
||||
|
||||
⸻
|
||||
**Don't use Command Models when:**
|
||||
- Forms are simple (use React state directly)
|
||||
- You're building a quick prototype
|
||||
- The form logic is trivial
|
||||
|
||||
Core Rules
|
||||
## 3) Core Rules
|
||||
|
||||
Command Models:
|
||||
• exist only in the frontend
|
||||
• are write-only (never reused for reads)
|
||||
• are created per form
|
||||
• are discarded after submission
|
||||
If you use Command Models:
|
||||
|
||||
Command Models MUST NOT:
|
||||
• contain business logic
|
||||
• enforce domain rules
|
||||
• reference View Models
|
||||
• reference Domain Entities or Value Objects
|
||||
• be sent to the API directly
|
||||
**They MUST:**
|
||||
- Live in `components/` or `hooks/` (not `lib/`)
|
||||
- Be write-only (never reused for reads)
|
||||
- Be discarded after submission
|
||||
- Only perform UX validation
|
||||
|
||||
⸻
|
||||
**They MUST NOT:**
|
||||
- Contain business logic
|
||||
- Enforce domain rules
|
||||
- Reference View Models or Domain Entities
|
||||
- Be sent to the API directly (use `toCommand()`)
|
||||
|
||||
Relationship to Other Models
|
||||
|
||||
API DTO (read) → ViewModel → UI
|
||||
|
||||
UI Input → FormModel → Command DTO → API
|
||||
|
||||
• View Models are read-only
|
||||
• Command Models are write-only
|
||||
• No model is reused across read/write boundaries
|
||||
|
||||
⸻
|
||||
|
||||
Typical Responsibilities
|
||||
|
||||
A Form Model MAY:
|
||||
• store field values
|
||||
• track dirty / touched state
|
||||
• perform basic UX validation
|
||||
• expose isValid, canSubmit
|
||||
• build a Command DTO
|
||||
|
||||
A Form Model MUST NOT:
|
||||
• decide if an action is allowed
|
||||
• perform authorization checks
|
||||
• validate cross-aggregate rules
|
||||
|
||||
⸻
|
||||
|
||||
Validation Guidelines
|
||||
|
||||
Client-side validation is UX validation, not business validation.
|
||||
|
||||
Allowed validation examples:
|
||||
• required fields
|
||||
• min / max length
|
||||
• email format
|
||||
• numeric ranges
|
||||
|
||||
Forbidden validation examples:
|
||||
• “user is not allowed”
|
||||
• “league already exists”
|
||||
• “quota exceeded”
|
||||
|
||||
Server validation is the source of truth.
|
||||
|
||||
⸻
|
||||
|
||||
Example: Simple Form Model (with class-validator)
|
||||
|
||||
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
|
||||
## 4) Example
|
||||
|
||||
```typescript
|
||||
// In your component file or hooks/
|
||||
export class SignupFormModel {
|
||||
@IsEmail()
|
||||
email = '';
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password = '';
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
reset(): void {
|
||||
this.email = '';
|
||||
this.password = '';
|
||||
isValid(): boolean {
|
||||
// UX validation only
|
||||
return this.email.includes('@') && this.password.length >= 8;
|
||||
}
|
||||
|
||||
toCommand(): SignupCommandDto {
|
||||
@@ -113,44 +69,51 @@ export class SignupFormModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export function SignupForm() {
|
||||
const [form] = useState(() => new SignupFormModel());
|
||||
|
||||
⸻
|
||||
async function handleSubmit() {
|
||||
if (!form.isValid()) return;
|
||||
|
||||
form.isSubmitting = true;
|
||||
const result = await signupMutation.mutateAsync(form.toCommand());
|
||||
form.isSubmitting = false;
|
||||
}
|
||||
|
||||
Usage in UI Component
|
||||
|
||||
const form = useFormModel(SignupFormModel);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.isValid()) return;
|
||||
|
||||
form.isSubmitting = true;
|
||||
|
||||
await authService.signup(form.toCommand());
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input value={form.email} onChange={e => form.email = e.target.value} />
|
||||
{/* ... */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The component:
|
||||
• binds inputs to the Form Model
|
||||
• reacts to validation state
|
||||
• never builds DTOs manually
|
||||
## 5) Key Principle
|
||||
|
||||
⸻
|
||||
**Command Models are optional.** The backend must validate everything.
|
||||
|
||||
Testing
|
||||
If you don't use Command Models, that's fine! Just:
|
||||
- Use React state for form data
|
||||
- Let the backend handle validation
|
||||
- Return clear errors from mutations
|
||||
|
||||
Command Models SHOULD be tested when they contain:
|
||||
• validation rules
|
||||
• non-trivial state transitions
|
||||
• command construction logic
|
||||
## 6) Comparison
|
||||
|
||||
Command Models do NOT need tests if they only hold fields without logic.
|
||||
| Approach | When to Use | Where |
|
||||
|----------|-------------|-------|
|
||||
| **React State** | Simple forms, prototypes | Component |
|
||||
| **Command Model** | Complex forms, multi-step | Component/Hook |
|
||||
| **View Model** | Read-only UI state | `lib/view-models/` |
|
||||
| **Service** | Business orchestration | `lib/services/` |
|
||||
|
||||
⸻
|
||||
## 7) Summary
|
||||
|
||||
Summary
|
||||
• Command Models are UX helpers for writes
|
||||
• They protect components from complexity
|
||||
• They never replace backend validation
|
||||
• They never leak into read flows
|
||||
Command Models are **optional UX sugar**. They:
|
||||
- Help organize complex forms
|
||||
- Are NOT required by the architecture
|
||||
- Don't need ESLint enforcement
|
||||
- Should stay in `components/` or `hooks/`
|
||||
|
||||
Command Models help users.
|
||||
Use Cases protect the system.
|
||||
Use them if they make your life easier. Skip them if they don't.
|
||||
281
docs/architecture/website/DEPENDENCY_CONSTRUCTION.md
Normal file
281
docs/architecture/website/DEPENDENCY_CONSTRUCTION.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Dependency Construction Architecture
|
||||
|
||||
## The Decision: Manual Construction (Not DI Container)
|
||||
|
||||
### Why Not Dependency Injection in RSC?
|
||||
|
||||
**Problem**: Next.js RSC pages run on every request, but DI containers are singletons.
|
||||
|
||||
**Risks with DI:**
|
||||
1. **Data Leakage**: Singleton container could share auth tokens between users
|
||||
2. **Request Context**: Can't inject request-specific data (user ID, auth token)
|
||||
3. **Scoping Complexity**: Would need request-scoped containers
|
||||
4. **Overhead**: DI adds complexity without benefit for RSC
|
||||
|
||||
**Example of the Problem:**
|
||||
```typescript
|
||||
// ❌ DON'T DO THIS - DI in RSC
|
||||
export default async function DashboardPage() {
|
||||
const container = ContainerManager.getInstance().getContainer();
|
||||
const service = container.get<DashboardService>(DASHBOARD_SERVICE_TOKEN);
|
||||
|
||||
// Problem: What if DashboardService needs the user's auth token?
|
||||
// The singleton container doesn't know which user this request is for!
|
||||
}
|
||||
```
|
||||
|
||||
### The Solution: Manual Construction
|
||||
|
||||
**Pattern:**
|
||||
```
|
||||
RSC Page
|
||||
↓
|
||||
PageQuery (constructs Service)
|
||||
↓
|
||||
Service (constructs API Client, Logger, ErrorReporter)
|
||||
↓
|
||||
API Client (makes HTTP calls)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// ✅ CORRECT: Manual construction in RSC
|
||||
export default async function DashboardPage() {
|
||||
const query = new DashboardPageQuery();
|
||||
const result = await query.execute();
|
||||
// ...
|
||||
}
|
||||
|
||||
// In DashboardPageQuery
|
||||
export class DashboardPageQuery {
|
||||
async execute() {
|
||||
const service = new DashboardService(); // Manual construction
|
||||
return await service.getDashboardOverview();
|
||||
}
|
||||
}
|
||||
|
||||
// In DashboardService
|
||||
export class DashboardService {
|
||||
private apiClient: DashboardApiClient;
|
||||
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
this.apiClient = new DashboardApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || '',
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardOverview() {
|
||||
return await this.apiClient.getOverview();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits of Manual Construction
|
||||
|
||||
1. **Explicit**: Dependencies are clear and visible
|
||||
2. **Simple**: No magic, no container, no configuration
|
||||
3. **Safe**: No singleton issues, no data leakage
|
||||
4. **Testable**: Easy to pass mocks in constructor
|
||||
5. **Flexible**: Can change dependencies without affecting callers
|
||||
|
||||
### What About the Existing `lib/di/`?
|
||||
|
||||
The project has an Inversify DI system, but it's designed for:
|
||||
|
||||
**Client Components:**
|
||||
```typescript
|
||||
// ✅ OK in client components
|
||||
export function UserDashboard() {
|
||||
const service = useInject(DASHBOARD_SERVICE_TOKEN);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
```typescript
|
||||
// ✅ OK in tests
|
||||
const mockApiClient = new MockDashboardApiClient();
|
||||
const service = new DashboardService(mockApiClient);
|
||||
```
|
||||
|
||||
**RSC (PageQueries/Mutations):**
|
||||
```typescript
|
||||
// ❌ DON'T use DI container in RSC
|
||||
// ✅ DO use manual construction
|
||||
```
|
||||
|
||||
### ESLint Enforcement
|
||||
|
||||
The `services-no-instantiation` rule is **removed** because it was wrong.
|
||||
|
||||
**Correct Rules:**
|
||||
- ✅ `clean-error-handling`: PageQueries must use Services, not API Clients
|
||||
- ✅ `services-implement-contract`: Services must return Result types
|
||||
- ✅ `lib-no-next-imports`: No Next.js imports in lib/ directory
|
||||
|
||||
**What the Rules Allow:**
|
||||
```typescript
|
||||
// In PageQuery - ALLOWED
|
||||
const service = new DashboardService();
|
||||
|
||||
// In Service - ALLOWED
|
||||
this.apiClient = new DashboardApiClient(...);
|
||||
this.logger = new ConsoleLogger();
|
||||
|
||||
// In PageQuery - FORBIDDEN
|
||||
const apiClient = new DashboardApiClient(...); // Use Service instead!
|
||||
```
|
||||
|
||||
### Complete Example: Read Flow
|
||||
|
||||
```typescript
|
||||
// apps/website/app/dashboard/page.tsx
|
||||
export default async function DashboardPage() {
|
||||
const query = new DashboardPageQuery();
|
||||
const result = await query.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
// Handle presentation errors
|
||||
return <ErrorDashboard error={result.error} />;
|
||||
}
|
||||
|
||||
return <DashboardTemplate viewData={result.value} />;
|
||||
}
|
||||
|
||||
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
|
||||
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
|
||||
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
|
||||
const service = new DashboardService();
|
||||
const result = await service.getDashboardOverview();
|
||||
|
||||
if (result.isErr()) {
|
||||
// Map domain error to presentation error
|
||||
return Result.err(mapToPresentationError(result.error));
|
||||
}
|
||||
|
||||
const viewData = DashboardViewDataBuilder.build(result.value);
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
}
|
||||
|
||||
// apps/website/lib/services/analytics/DashboardService.ts
|
||||
export class DashboardService {
|
||||
private apiClient: DashboardApiClient;
|
||||
|
||||
constructor() {
|
||||
this.apiClient = new DashboardApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || '',
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardOverview(): Promise<Result<DashboardOverviewDTO, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getOverview();
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
// Convert HTTP errors to domain errors
|
||||
if (error instanceof HttpNotFoundError) {
|
||||
return Result.err(new NotFoundError('Dashboard not found'));
|
||||
}
|
||||
return Result.err(new UnknownError('Failed to fetch dashboard'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apps/website/lib/api/dashboard/DashboardApiClient.ts
|
||||
export class DashboardApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private errorReporter: ErrorReporter,
|
||||
private logger: Logger
|
||||
) {}
|
||||
|
||||
async getOverview(): Promise<DashboardOverviewDTO> {
|
||||
const response = await fetch(`${this.baseUrl}/dashboard/overview`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new HttpNotFoundError('Dashboard not found');
|
||||
}
|
||||
throw new HttpError(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example: Write Flow
|
||||
|
||||
```typescript
|
||||
// apps/website/app/actions/userActions.ts
|
||||
'use server';
|
||||
|
||||
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
const result = await mutation.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
|
||||
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
|
||||
async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
|
||||
const service = new UserService();
|
||||
const result = await service.updateUserStatus(input.userId, input.status);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToMutationError(result.error));
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// apps/website/lib/services/user/UserService.ts
|
||||
export class UserService {
|
||||
private apiClient: UserApiClient;
|
||||
|
||||
constructor() {
|
||||
this.apiClient = new UserApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || '',
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserStatus(userId: string, status: string): Promise<Result<void, DomainError>> {
|
||||
try {
|
||||
await this.apiClient.updateUserStatus(userId, status);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpForbiddenError) {
|
||||
return Result.err(new ForbiddenError('Insufficient permissions'));
|
||||
}
|
||||
return Result.err(new UnknownError('Failed to update user'));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
| Aspect | RSC (PageQueries/Mutations) | Client Components |
|
||||
|--------|----------------------------|-------------------|
|
||||
| **Construction** | Manual (`new Service()`) | Manual or DI hooks |
|
||||
| **DI Container** | ❌ Never use | ✅ Can use |
|
||||
| **Dependencies** | Service creates its own | Injected or manual |
|
||||
| **Testability** | Pass mocks to constructor | Use DI mocks |
|
||||
| **Complexity** | Low | Medium |
|
||||
|
||||
**Golden Rule**: In RSC, always use manual construction. It's simpler, safer, and more explicit.
|
||||
@@ -62,11 +62,15 @@ class HttpServerError extends HttpError {}
|
||||
|
||||
### Layer 2: Service (Technical → Domain Errors)
|
||||
|
||||
The Service catches HTTP errors and converts them to domain errors:
|
||||
The Service creates its own dependencies and converts HTTP errors to domain errors.
|
||||
|
||||
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why Services create their own dependencies.
|
||||
|
||||
```typescript
|
||||
// apps/website/lib/services/dashboard/DashboardService.ts
|
||||
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
|
||||
export type DashboardServiceError =
|
||||
| { type: 'notFound'; message: string }
|
||||
@@ -76,7 +80,17 @@ export type DashboardServiceError =
|
||||
| { type: 'unknown'; message: string };
|
||||
|
||||
export class DashboardService {
|
||||
constructor(private apiClient: DashboardApiClient) {}
|
||||
private apiClient: DashboardApiClient;
|
||||
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
this.apiClient = new DashboardApiClient(
|
||||
baseUrl,
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardOverview(): Promise<Result<DashboardStats, DashboardServiceError>> {
|
||||
try {
|
||||
@@ -105,9 +119,17 @@ export class DashboardService {
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- ✅ Service creates its own API Client
|
||||
- ✅ Service creates its own Logger and ErrorReporter
|
||||
- ✅ Catches HTTP errors and converts to domain errors
|
||||
- ✅ Returns Result type
|
||||
|
||||
### Layer 3: PageQuery (Domain → Presentation Errors)
|
||||
|
||||
PageQueries use Services and map domain errors to presentation errors:
|
||||
PageQueries use Services and map domain errors to presentation errors.
|
||||
|
||||
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why we use manual construction.
|
||||
|
||||
```typescript
|
||||
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
|
||||
@@ -118,12 +140,8 @@ type DashboardPageError = 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | '
|
||||
|
||||
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
|
||||
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
|
||||
// Manual wiring
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const apiClient = new DashboardApiClient(baseUrl, errorReporter, logger);
|
||||
const dashboardService = new DashboardService(apiClient);
|
||||
// Manual construction: Service creates its own dependencies
|
||||
const dashboardService = new DashboardService();
|
||||
|
||||
// Call service
|
||||
const serviceResult = await dashboardService.getDashboardOverview();
|
||||
@@ -154,6 +172,13 @@ export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- ✅ PageQuery constructs only the Service
|
||||
- ✅ Service handles its own dependencies (API Client, Logger, etc.)
|
||||
- ❌ No API Client instantiation in PageQuery
|
||||
- ✅ Map domain errors to presentation errors
|
||||
- ✅ Transform API DTO to ViewData using Builder
|
||||
|
||||
### Layer 4: RSC Page (Presentation → User)
|
||||
|
||||
The RSC page handles presentation errors:
|
||||
@@ -282,6 +307,10 @@ export class UserApiClient {
|
||||
|
||||
```typescript
|
||||
// apps/website/lib/services/user/UserService.ts
|
||||
import { UserApiClient } from '@/lib/api/user/UserApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
|
||||
export type UserServiceError =
|
||||
| { type: 'notFound'; message: string }
|
||||
| { type: 'forbidden'; message: string }
|
||||
@@ -289,7 +318,17 @@ export type UserServiceError =
|
||||
| { type: 'serverError'; message: string };
|
||||
|
||||
export class UserService {
|
||||
constructor(private apiClient: UserApiClient) {}
|
||||
private apiClient: UserApiClient;
|
||||
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
this.apiClient = new UserApiClient(
|
||||
baseUrl,
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserStatus(userId: string, status: string): Promise<Result<void, UserServiceError>> {
|
||||
try {
|
||||
@@ -314,6 +353,12 @@ export class UserService {
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- ✅ Service creates its own API Client
|
||||
- ✅ Service creates its own Logger and ErrorReporter
|
||||
- ✅ Catches HTTP errors and converts to domain errors
|
||||
- ✅ Returns Result type
|
||||
|
||||
### Layer 3: Mutation (Domain → Presentation Errors)
|
||||
|
||||
```typescript
|
||||
@@ -358,18 +403,11 @@ export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput,
|
||||
'use server';
|
||||
|
||||
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
|
||||
import { UserService } from '@/lib/services/user/UserService';
|
||||
import { UserApiClient } from '@/lib/api/user/UserApiClient';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
// Manual wiring
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const apiClient = new UserApiClient(baseUrl, errorReporter, logger);
|
||||
const userService = new UserService(apiClient);
|
||||
const mutation = new UpdateUserStatusMutation(userService);
|
||||
// Manual construction: Mutation creates Service, Service creates dependencies
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
|
||||
const result = await mutation.execute(input);
|
||||
|
||||
@@ -384,6 +422,12 @@ export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- ✅ Server Action constructs only the Mutation
|
||||
- ✅ Mutation constructs the Service
|
||||
- ✅ Service constructs its own dependencies
|
||||
- ✅ No manual wiring needed
|
||||
|
||||
### Layer 5: Client Component (Handles Result)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -37,15 +37,38 @@ apps/website/lib/services/
|
||||
|
||||
### Service Definition
|
||||
|
||||
Services create their own dependencies:
|
||||
|
||||
```typescript
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
|
||||
export class AdminService {
|
||||
constructor(private readonly apiClient: AdminApiClient) {}
|
||||
private apiClient: AdminApiClient;
|
||||
|
||||
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
|
||||
return this.apiClient.updateUserStatus(userId, status);
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
this.apiClient = new AdminApiClient(
|
||||
baseUrl,
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserStatus(userId: string, status: string): Promise<Result<UserDto, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.updateUserStatus(userId, status);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
// Convert HTTP errors to domain errors
|
||||
if (error instanceof HttpForbiddenError) {
|
||||
return Result.err(new ForbiddenError('Insufficient permissions'));
|
||||
}
|
||||
return Result.err(new UnknownError('Failed to update user'));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -54,101 +77,113 @@ export class AdminService {
|
||||
|
||||
```typescript
|
||||
// apps/website/lib/page-queries/AdminDashboardPageQuery.ts
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
|
||||
|
||||
export class AdminDashboardPageQuery {
|
||||
async execute(): Promise<PageQueryResult<AdminDashboardPageDto>> {
|
||||
// Create infrastructure
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {...});
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||
const service = new AdminService(apiClient);
|
||||
export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
|
||||
async execute(): Promise<Result<AdminDashboardViewData, DashboardPageError>> {
|
||||
// Manual construction: Service creates its own dependencies
|
||||
const service = new AdminService();
|
||||
|
||||
// Use service
|
||||
const stats = await service.getDashboardStats();
|
||||
const result = await service.getDashboardStats();
|
||||
|
||||
// Transform to Page DTO
|
||||
return { status: 'ok', dto: transformToPageDto(stats) };
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToPresentationError(result.error));
|
||||
}
|
||||
|
||||
// Transform to ViewData using Builder
|
||||
const viewData = AdminDashboardViewDataBuilder.build(result.value);
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Server Actions (Writes)
|
||||
### Usage in Mutations (Writes)
|
||||
|
||||
```typescript
|
||||
// apps/website/app/admin/actions.ts
|
||||
'use server';
|
||||
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function updateUserStatus(userId: string, status: string): Promise<void> {
|
||||
try {
|
||||
// Create infrastructure
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
|
||||
async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
|
||||
// Manual construction: Service creates its own dependencies
|
||||
const service = new AdminService();
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||
const service = new AdminService(apiClient);
|
||||
const result = await service.updateUserStatus(input.userId, input.status);
|
||||
|
||||
// Use service (NOT API client directly)
|
||||
await service.updateUserStatus(userId, status);
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToMutationError(result.error));
|
||||
}
|
||||
|
||||
// Revalidate
|
||||
revalidatePath('/admin/users');
|
||||
} catch (error) {
|
||||
console.error('updateUserStatus failed:', error);
|
||||
throw new Error('Failed to update user status');
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Infrastructure Concerns
|
||||
### Usage in Server Actions
|
||||
|
||||
**Where should logging/error reporting live?**
|
||||
```typescript
|
||||
// app/admin/actions.ts
|
||||
'use server';
|
||||
|
||||
In the current architecture, **server actions and PageQueries create infrastructure**. This is acceptable because:
|
||||
1. Next.js serverless functions are stateless
|
||||
2. Each request needs fresh infrastructure
|
||||
3. Manual DI is clearer than magic containers
|
||||
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
**Key principle**: Services orchestrate, they don't create infrastructure.
|
||||
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
// Manual construction: Mutation creates Service
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
|
||||
const result = await mutation.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
Server Action / PageQuery
|
||||
↓ (creates infrastructure)
|
||||
RSC Page / Server Action
|
||||
↓ (manual construction)
|
||||
PageQuery / Mutation
|
||||
↓ (manual construction)
|
||||
Service
|
||||
↓ (orchestrates)
|
||||
↓ (creates own dependencies)
|
||||
API Client
|
||||
↓ (makes HTTP calls)
|
||||
API
|
||||
```
|
||||
|
||||
**Key Principle**: Each layer manually constructs the next layer. Services create their own infrastructure (API Client, Logger, ErrorReporter).
|
||||
|
||||
## Why Manual Construction?
|
||||
|
||||
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md)
|
||||
|
||||
**Summary**:
|
||||
- ✅ Explicit and clear
|
||||
- ✅ No singleton issues
|
||||
- ✅ No request-scoping problems
|
||||
- ✅ Easy to test (pass mocks to constructor)
|
||||
- ✅ Works perfectly with Next.js RSC
|
||||
- ❌ No DI container needed
|
||||
|
||||
## Naming
|
||||
|
||||
- Service classes: `*Service`
|
||||
- Service methods: Return DTOs (not ViewModels)
|
||||
- Variable names: `apiDto`, `pageDto` (never just `dto`)
|
||||
- Service methods: Return `Result<T, DomainError>`
|
||||
- Variable names: `apiDto`, `viewData` (never just `dto`)
|
||||
|
||||
## Comparison with Other Layers
|
||||
|
||||
| Layer | Purpose | Example |
|
||||
|-------|---------|---------|
|
||||
| **Website Service** | Orchestrate API calls | `AdminService` |
|
||||
| **Website Service** | Orchestrate API calls, handle errors | `AdminService` |
|
||||
| **API Client** | HTTP infrastructure | `AdminApiClient` |
|
||||
| **Core Use Case** | Business rules | `CreateLeagueUseCase` |
|
||||
| **Domain Service** | Cross-entity logic | `StrengthOfFieldCalculator` |
|
||||
@@ -170,8 +205,13 @@ class AdminService {
|
||||
```typescript
|
||||
// CORRECT
|
||||
class AdminService {
|
||||
async getUser(userId: string): Promise<UserDto> {
|
||||
return this.apiClient.getUser(userId); // ✅ DTOs are fine
|
||||
async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
|
||||
try {
|
||||
const dto = await this.apiClient.getUser(userId);
|
||||
return Result.ok(dto); // ✅ DTOs are fine
|
||||
} catch (error) {
|
||||
return Result.err(new NotFoundError('User not found'));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -191,8 +231,13 @@ class AdminService {
|
||||
```typescript
|
||||
// CORRECT
|
||||
class AdminService {
|
||||
async getUser(userId: string): Promise<UserDto> {
|
||||
return this.apiClient.getUser(userId);
|
||||
async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
|
||||
try {
|
||||
const dto = await this.apiClient.getUser(userId);
|
||||
return Result.ok(dto);
|
||||
} catch (error) {
|
||||
return Result.err(new NotFoundError('User not found'));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Business logic in core use case or page query
|
||||
@@ -208,24 +253,48 @@ export async function updateUserStatus(userId: string, status: string) {
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Correct**: Server action uses service
|
||||
✅ **Correct**: Server action uses Mutation
|
||||
```typescript
|
||||
// CORRECT
|
||||
'use server';
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
const apiClient = new AdminApiClient(...);
|
||||
const service = new AdminService(apiClient);
|
||||
await service.updateUserStatus(userId, status); // ✅ Uses service
|
||||
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
const result = await mutation.execute(input);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
❌ **Wrong**: PageQuery creates API Client
|
||||
```typescript
|
||||
// WRONG
|
||||
export class DashboardPageQuery {
|
||||
async execute() {
|
||||
const apiClient = new DashboardApiClient(...); // ❌ Should use Service
|
||||
return await apiClient.getOverview();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Correct**: PageQuery uses Service
|
||||
```typescript
|
||||
// CORRECT
|
||||
export class DashboardPageQuery {
|
||||
async execute() {
|
||||
const service = new DashboardService(); // ✅ Service creates API Client
|
||||
return await service.getDashboardOverview();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Website services are **thin orchestration wrappers** around API clients. They handle infrastructure concerns so that PageQueries and Server Actions can focus on composition and validation.
|
||||
Website services are **thin orchestration wrappers** that create their own dependencies and handle error conversion.
|
||||
|
||||
**Key principles**:
|
||||
1. Services orchestrate API calls
|
||||
2. Server actions/PageQueries create infrastructure
|
||||
3. Services don't create ViewModels
|
||||
4. Services don't contain business rules
|
||||
5. **Server actions MUST use services, not API clients directly**
|
||||
1. ✅ Services create their own dependencies (API Client, Logger, ErrorReporter)
|
||||
2. ✅ Services return `Result<T, DomainError>`
|
||||
3. ✅ Services convert HTTP errors to Domain errors
|
||||
4. ❌ Services don't create ViewModels
|
||||
5. ❌ Services don't contain business rules
|
||||
6. ✅ PageQueries/Mutations use Services, not API Clients directly
|
||||
7. ✅ Manual construction (no DI container in RSC)
|
||||
@@ -148,10 +148,11 @@ Purpose: eliminate exceptions and provide explicit error paths.
|
||||
|
||||
Rules:
|
||||
|
||||
- All PageQueries return `Result<ApiDto, string>`
|
||||
- All Mutations return `Result<void, string>`
|
||||
- Use `ResultFactory.ok(value)` for success
|
||||
- Use `ResultFactory.error(message)` for errors
|
||||
- All PageQueries return `Result<ViewData, PresentationError>`
|
||||
- All Mutations return `Result<void, MutationError>`
|
||||
- All Services return `Result<ApiDto, DomainError>`
|
||||
- Use `Result.ok(value)` for success
|
||||
- Use `Result.err(error)` for errors
|
||||
- Never throw exceptions
|
||||
|
||||
See [`Result.ts`](apps/website/lib/contracts/Result.ts:1).
|
||||
@@ -183,13 +184,19 @@ Canonical placement in this repo:
|
||||
```text
|
||||
RSC page.tsx
|
||||
↓
|
||||
PageQuery.execute()
|
||||
PageQuery (manual construction)
|
||||
↓
|
||||
API client (infra)
|
||||
Service (creates own API Client, Logger, ErrorReporter)
|
||||
↓
|
||||
API Client (makes HTTP calls)
|
||||
↓
|
||||
API Transport DTO
|
||||
↓
|
||||
Result<ApiDto, string>
|
||||
Result<ApiDto, DomainError>
|
||||
↓
|
||||
PageQuery (maps DomainError → PresentationError)
|
||||
↓
|
||||
Result<ViewData, PresentationError>
|
||||
↓
|
||||
ViewData Builder (lib/builders/view-data/)
|
||||
↓
|
||||
@@ -198,6 +205,13 @@ ViewData
|
||||
Template
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- PageQuery constructs Service
|
||||
- Service creates its own dependencies
|
||||
- Service returns Result<ApiDto, DomainError>
|
||||
- PageQuery maps errors to presentation layer
|
||||
- Builder transforms API DTO to ViewData
|
||||
|
||||
### Client Components
|
||||
```text
|
||||
Client Component
|
||||
@@ -241,40 +255,55 @@ Allowed:
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
const service = new AdminService(...);
|
||||
const service = new AdminService();
|
||||
await service.updateUserStatus(userId, status); // ❌ Should use mutation
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Mutation usage
|
||||
'use server';
|
||||
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
|
||||
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
const mutation = new AdminUserMutation();
|
||||
const result = await mutation.updateUserStatus(userId, status);
|
||||
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
const result = await mutation.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
console.error('updateUserStatus failed:', result.getError());
|
||||
throw new Error('Failed to update user status');
|
||||
console.error('updateUserStatus failed:', result.error);
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**:
|
||||
1. Server Action (thin wrapper) - handles framework concerns (revalidation)
|
||||
2. Mutation (framework-agnostic) - creates infrastructure, calls service
|
||||
3. Service (business logic) - orchestrates API calls
|
||||
4. API Client (infrastructure) - makes HTTP requests
|
||||
5. Result - type-safe error handling
|
||||
1. **Server Action** (thin wrapper) - handles framework concerns (revalidation, returns to client)
|
||||
2. **Mutation** (framework-agnostic) - constructs Service, calls service methods
|
||||
3. **Service** - constructs own dependencies (API Client, Logger), returns Result
|
||||
4. **API Client** (infrastructure) - makes HTTP requests, throws HTTP errors
|
||||
5. **Result** - type-safe error handling at every layer
|
||||
|
||||
**Dependency Flow**:
|
||||
```
|
||||
Server Action
|
||||
↓ (constructs)
|
||||
Mutation
|
||||
↓ (constructs)
|
||||
Service (creates API Client, Logger, ErrorReporter)
|
||||
↓ (calls)
|
||||
API Client
|
||||
↓ (HTTP)
|
||||
Backend API
|
||||
```
|
||||
|
||||
**Rationale**:
|
||||
- Mutations are framework-agnostic (can be tested without Next.js)
|
||||
- Consistent pattern with PageQueries
|
||||
- Type-safe error handling with Result
|
||||
- Makes infrastructure explicit and testable
|
||||
- Manual construction (no DI container issues)
|
||||
|
||||
See [`MUTATIONS.md`](docs/architecture/website/MUTATIONS.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user