Перейти к содержимому

05 -- API Contracts: Admin Service

#MethodPathGuardRolesRequest DTOResponse DTOSuccessError Codes
1POST/v1/admin/auth/registerNoneNoneAdminRegisterDto{ message: string }201400, 409
2POST/v1/admin/auth/register/verifyNoneNoneAdminRegisterVerifyDtoAdminAuthResponseDto201400, 404, 429
3POST/v1/admin/auth/loginNoneNoneAdminLoginDto{ message: string }200400, 404
4POST/v1/admin/auth/login/verifyNoneNoneAdminLoginVerifyDtoAdminAuthResponseDto200400, 404, 429
5POST/v1/admin/auth/refreshAdminRefreshTokenGuardADMINHeaders only{ token: string }200401
6GET/v1/admin/projectsAdminAccessTokenGuardADMINAdminProjectListQueryDto (query)PaginatedResponse<AdminProjectResponseDto>200401
7GET/v1/admin/projects/:idAdminAccessTokenGuardADMINPath: id (UUID)AdminProjectResponseDto200401, 404
8PATCH/v1/admin/projects/:idAdminAccessTokenGuardADMINAdminProjectUpdateDtoAdminProjectResponseDto200400, 401, 404
9DELETE/v1/admin/projects/:idAdminAccessTokenGuardADMINPath: id (UUID)None204401, 404
10GET/v1/admin/usersAdminAccessTokenGuardADMINAdminUserListQueryDto (query)PaginatedResponse<AdminUserResponseDto>200401
11GET/v1/admin/users/:idAdminAccessTokenGuardADMINPath: id (UUID)AdminUserDetailResponseDto200401, 404
12PATCH/v1/admin/users/:id/blockAdminAccessTokenGuardADMINPath: id (UUID){ message: string }200401, 403, 404
13PATCH/v1/admin/users/:id/unblockAdminAccessTokenGuardADMINPath: id (UUID){ message: string }200401, 404

classDiagram
class AdminRegisterDto {
+string email
%% @IsEmail()
%% @Trim()
%% Validated against ADMIN_ALLOWED_DOMAINS
}
class AdminRegisterVerifyDto {
+string email
+string code
+string fingerprint
%% @IsEmail()
%% @IsString() @MinLength(6) @MaxLength(6)
%% @IsString()
}
class AdminLoginDto {
+string email
%% @IsEmail()
%% @Trim()
}
class AdminLoginVerifyDto {
+string email
+string code
+string fingerprint
%% @IsEmail()
%% @IsString() @MinLength(6) @MaxLength(6)
%% @IsString()
}
class AdminAuthResponseDto {
+string accessToken
+string refreshToken
%% @Expose()
%% Set-Cookie header also sent
}
class AdminRefreshResponseDto {
+string token
%% @Expose()
}
AdminRegisterVerifyDto --> AdminAuthResponseDto : returns
AdminLoginVerifyDto --> AdminAuthResponseDto : returns
classDiagram
class BaseDto {
+string id
+Date createdAt
+Date updatedAt
%% @Expose() @IsUUID()
%% @Expose() @IsDate()
}
class AdminProjectOwnerDto {
+string id
+string email
%% @Expose() @IsUUID()
%% @Expose() @IsEmail()
}
class AdminProjectResponseDto {
+string id
+string title
+string description
+ProjectCategoryType? category
+ProjectStatus status
+string customerId
+Date createdAt
+Date updatedAt
+AdminProjectOwnerDto owner
%% @Expose() for all fields
%% @ValidateNested() @Type(() => AdminProjectOwnerDto)
}
class AdminProjectUpdateDto {
+string? title
+string? description
+ProjectCategoryType? category
+ProjectStatus? status
%% All fields @IsOptional()
%% title: @IsString() @MinLength(3) @MaxLength(255) @Trim()
%% description: @IsString() @MinLength(3) @Trim()
%% category: @IsEnum(ProjectCategoryType)
%% status: @IsEnum(ProjectStatus) validated against PROJECT_STATUS_TRANSITIONS
}
class AdminProjectListQueryDto {
+number? page
+number? perPage
+ProjectStatus? status
+ProjectCategoryType? category
+string? search
%% page: @IsOptional() @IsInt() @Min(1) default 1
%% perPage: @IsOptional() @IsInt() @Min(1) default 10
%% status: @IsOptional() @IsEnum(ProjectStatus)
%% category: @IsOptional() @IsEnum(ProjectCategoryType)
%% search: @IsOptional() @IsString() @Trim()
}
BaseDto <|-- AdminProjectResponseDto
AdminProjectResponseDto *-- AdminProjectOwnerDto : owner
classDiagram
class AdminUserRoleDto {
+string roleType
%% @Expose()
%% RoleType: CUSTOMER | EMPLOYEE | ADMIN
}
class AdminUserResponseDto {
+string id
+string email
+string? phone
+boolean isEmailVerified
+boolean isBlocked
+Date createdAt
+Date updatedAt
+AdminUserRoleDto[] roles
%% @Expose() for all fields
%% @ValidateNested(each: true) @Type(() => AdminUserRoleDto)
}
class AdminUserDetailResponseDto {
+string id
+string email
+string? phone
+boolean isEmailVerified
+boolean isBlocked
+Date createdAt
+Date updatedAt
+AdminUserRoleDto[] roles
+AdminProjectResponseDto[] projects
%% Extends AdminUserResponseDto with projects
}
class AdminUserListQueryDto {
+number? page
+number? perPage
+string? role
+boolean? isBlocked
+string? search
%% page: @IsOptional() @IsInt() @Min(1) default 1
%% perPage: @IsOptional() @IsInt() @Min(1) default 10
%% role: @IsOptional() @IsEnum(RoleType)
%% isBlocked: @IsOptional() @IsBoolean()
%% search: @IsOptional() @IsString() @Trim() (searches by email)
}
AdminUserResponseDto *-- AdminUserRoleDto : roles
AdminUserResponseDto <|-- AdminUserDetailResponseDto
AdminUserDetailResponseDto *-- AdminProjectResponseDto : projects
class AdminProjectResponseDto {
+string id
+string title
+ProjectStatus status
+Date createdAt
...
}

stateDiagram-v2
[*] --> DRAFT
DRAFT --> PUBLISHED : Customer: publish\nAdmin: PATCH /admin/projects/:id
PUBLISHED --> HIRING : Customer: start hiring\nAdmin: PATCH /admin/projects/:id
PUBLISHED --> CANCELLED : Customer: cancel\nAdmin: PATCH /admin/projects/:id
HIRING --> IN_PROGRESS : Customer: start work\nAdmin: PATCH /admin/projects/:id
HIRING --> CANCELLED : Customer: cancel\nAdmin: PATCH /admin/projects/:id
IN_PROGRESS --> REVIEW : Customer: submit for review\nAdmin: PATCH /admin/projects/:id
IN_PROGRESS --> CANCELLED : Customer: cancel\nAdmin: PATCH /admin/projects/:id
IN_PROGRESS --> DISPUTED : Customer: raise dispute\nAdmin: PATCH /admin/projects/:id
REVIEW --> COMPLETED : Customer: approve\nAdmin: PATCH /admin/projects/:id
REVIEW --> IN_PROGRESS : Customer: request changes\nAdmin: PATCH /admin/projects/:id
DISPUTED --> CANCELLED : Admin: PATCH /admin/projects/:id\nAdmin: resolve dispute
DISPUTED --> IN_PROGRESS : Admin: PATCH /admin/projects/:id\nAdmin: resolve dispute
COMPLETED --> [*]
CANCELLED --> [*]

Transition rules enforced at the service layer:

Current StatusAllowed Transitions
DRAFTPUBLISHED
PUBLISHEDHIRING, CANCELLED
HIRINGIN_PROGRESS, CANCELLED
IN_PROGRESSREVIEW, CANCELLED, DISPUTED
REVIEWCOMPLETED, IN_PROGRESS
DISPUTEDCANCELLED, IN_PROGRESS
COMPLETED(terminal — no transitions allowed)
CANCELLED(terminal — no transitions allowed)

When an admin sends PATCH /v1/admin/projects/:id with a status field, the service validates the requested transition against PROJECT_STATUS_TRANSITIONS. If the transition is not in the allowed list, a 400 Bad Request is returned with the message "Invalid status transition from {current} to {requested}".


Request:

POST /v1/admin/auth/register/verify HTTP/1.1
Content-Type: application/json
{
"email": "admin@crewsforge.com",
"code": "482910",
"fingerprint": "d7a8fbb307d7809469ca9abcb0082e4f"
}

Response (201 Created):

HTTP/1.1 201 Created
Set-Cookie: refreshToken=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; Path=/v1/admin/auth; SameSite=Strict
Content-Type: application/json
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBjOGEwMC0xYTJiLTRjM2QtOGU0Zi01NjZhN2I4OWMwZGUiLCJyb2xlcyI6WyJBRE1JTiJdLCJpYXQiOjE3MTExMDAwMDAsImV4cCI6MTcxMTEwMzYwMH0.abc123",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1NTBjOGEwMC0xYTJiLTRjM2QtOGU0Zi01NjZhN2I4OWMwZGUiLCJpYXQiOjE3MTExMDAwMDAsImV4cCI6MTcxMTcwNDgwMH0.def456"
}

Token payload (decoded accessToken):

{
"userId": "550c8a00-1a2b-4c3d-8e4f-566a7b89c0de",
"roles": ["ADMIN"],
"iat": 1711100000,
"exp": 1711103600
}

Error (400 — invalid/expired code):

{
"statusCode": 400,
"message": "Verification code is invalid or expired",
"error": "Bad Request"
}

Request:

GET /v1/admin/projects?page=1&perPage=2&status=IN_PROGRESS&search=website HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Response (200 OK):

{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Corporate Website Redesign",
"description": "Full redesign of the corporate website with new branding guidelines...",
"category": "WEB_DEVELOPMENT",
"status": "IN_PROGRESS",
"customerId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-03-10T14:22:00.000Z",
"owner": {
"id": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
"email": "client@example.com"
}
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"title": "Website Performance Optimization",
"description": "Optimize page load times and Core Web Vitals for the main website...",
"category": "WEB_DEVELOPMENT",
"status": "IN_PROGRESS",
"customerId": "d2e3f4a5-b6c7-8901-defg-234567890bcd",
"createdAt": "2026-02-01T08:00:00.000Z",
"updatedAt": "2026-03-18T16:45:00.000Z",
"owner": {
"id": "d2e3f4a5-b6c7-8901-defg-234567890bcd",
"email": "another-client@example.com"
}
}
],
"meta": {
"totalCount": 7,
"pageCount": 4,
"currentPage": 1,
"perPage": 2,
"isFirstPage": true,
"isLastPage": false
}
}

Request:

PATCH /v1/admin/users/550c8a00-1a2b-4c3d-8e4f-566a7b89c0de/block HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Response (200 OK — blocking a customer/employee):

{
"message": "User has been blocked successfully"
}

Error (403 Forbidden — attempting to block an admin):

{
"statusCode": 403,
"message": "Cannot block a user with ADMIN role",
"error": "Forbidden"
}

Error (404 Not Found — user does not exist):

{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}

All errors follow the standard NestJS HttpException shape:

{
"statusCode": <number>,
"message": "<string | string[]>",
"error": "<HTTP status text>"
}
StatusErrorEndpointsCondition
400Bad RequestPOST /admin/auth/registerEmail domain not in ADMIN_ALLOWED_DOMAINS
400Bad RequestPOST /admin/auth/register/verify, POST /admin/auth/login/verifyVerification code is invalid or expired
400Bad RequestPATCH /admin/projects/:idInvalid status transition (e.g., COMPLETED to DRAFT)
400Bad RequestAny with body/queryDTO validation failure (class-validator) — message is an array of constraint violations
401UnauthorizedAll guarded endpointsMissing, malformed, or expired access/refresh token
401UnauthorizedAll guarded endpointsUser associated with token has isBlocked = true
403ForbiddenPATCH /admin/users/:id/blockTarget user has the ADMIN role
404Not FoundPOST /admin/auth/loginNo user found with given email and ADMIN role
404Not FoundGET /admin/projects/:id, PATCH /admin/projects/:id, DELETE /admin/projects/:idProject not found or already soft-deleted (deletedAt IS NOT NULL)
404Not FoundGET /admin/users/:id, PATCH /admin/users/:id/block, PATCH /admin/users/:id/unblockUser not found
409ConflictPOST /admin/auth/registerUser with this email already exists
429Too Many RequestsPOST /admin/auth/register, POST /admin/auth/loginVerification code resend cooldown has not expired (120s)

When class-validator rejects a request body, the response contains an array of human-readable constraint messages:

{
"statusCode": 400,
"message": [
"title must be longer than or equal to 3 characters",
"status must be one of the following values: DRAFT, PUBLISHED, HIRING, IN_PROGRESS, REVIEW, COMPLETED, CANCELLED, DISPUTED"
],
"error": "Bad Request"
}

HeaderRequiredDescriptionUsed By
AuthorizationYesBearer <accessToken> or Bearer <refreshToken>All guarded endpoints
X-FingerprintYes (refresh only)Client fingerprint stringPOST /admin/auth/refresh
X-Session-IdYes (refresh only)Active session UUIDPOST /admin/auth/refresh
DecoratorSourcePurpose
@GetTokenPayload()libs/apis/sharedExtracts decoded JWT payload from request
@HeaderFingerprint()libs/apis/sharedExtracts X-Fingerprint header value
@HeaderSessionId()libs/apis/sharedExtracts X-Session-Id header value
@UseGuards(AdminAccessTokenGuard)@nestjs/common + sharedProtects admin CRUD endpoints
@UseGuards(AdminRefreshTokenGuard)@nestjs/common + sharedProtects the refresh endpoint

All list endpoints (GET /admin/projects, GET /admin/users) use the shared pagination contract powered by MapperService.toPaginateResponse().

ParameterTypeDefaultConstraints
pagenumber1@IsInt() @Min(1)
perPagenumber10@IsInt() @Min(1)
interface PaginatedResponse<T> {
data: T[];
meta: {
totalCount: number; // Total records matching filters
pageCount: number; // Total pages (ceil(totalCount / perPage))
currentPage: number; // Current page number
perPage: number; // Items per page
isFirstPage: boolean; // currentPage === 1
isLastPage: boolean; // currentPage === pageCount
};
}