Leadership Module Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the leadership module that allows users to establish leader-follower relationships via bidirectional requests with explicit acceptance, transfer/unlink flows, and an org chart view.
Architecture: Hexagonal architecture following existing module patterns (CQRS with CommandBus/QueryBus, Repository pattern with Prisma, PermissionGuard for authorization). Two new DB tables: leadership_requests (all requests + unlink notifications) + leader_id/is_leader fields on Users. Permissions added to the catalog via migration.
Tech Stack: NestJS, CQRS (@nestjs/cqrs), Prisma (PostgreSQL), Jest, class-validator, Swagger
File Map
New files:
prisma/models/leadership.prisma
prisma/migrations/20260411120000_add_leadership_permissions/migration.sql
prisma/migrations/20260411120001_add_leader_id_to_users/migration.sql
prisma/migrations/20260411120002_create_leadership_requests/migration.sql
src/modules/leadership/
├── domain/
│ ├── enums/leadership_request_type.enum.ts
│ ├── enums/leadership_request_status.enum.ts
│ └── entities/leadership_request.entity.ts
├── application/
│ ├── dtos/leadership_request.dto.ts
│ ├── dtos/leadership_user_search.dto.ts
│ ├── dtos/organigrama.dto.ts
│ ├── commands/send_request/send_leadership_request.command.ts
│ ├── commands/send_request/send_leadership_request.handler.ts
│ ├── commands/send_request/send_leadership_request.handler.spec.ts
│ ├── commands/resolve_request/resolve_leadership_request.command.ts
│ ├── commands/resolve_request/resolve_leadership_request.handler.ts
│ ├── commands/resolve_request/resolve_leadership_request.handler.spec.ts
│ ├── commands/cancel_request/cancel_leadership_request.command.ts
│ ├── commands/cancel_request/cancel_leadership_request.handler.ts
│ ├── commands/cancel_request/cancel_leadership_request.handler.spec.ts
│ ├── commands/unlink/unlink_leadership.command.ts
│ ├── commands/unlink/unlink_leadership.handler.ts
│ ├── commands/unlink/unlink_leadership.handler.spec.ts
│ ├── queries/list_requests/list_leadership_requests.query.ts
│ ├── queries/list_requests/list_leadership_requests.handler.ts
│ ├── queries/search_users/search_leadership_users.query.ts
│ ├── queries/search_users/search_leadership_users.handler.ts
│ └── queries/get_organigrama/get_organigrama.query.ts
│ └── queries/get_organigrama/get_organigrama.handler.ts
├── infrastructure/
│ ├── db/leadership_request.repository.ts
│ ├── db/leadership_request.prisma.ts
│ ├── exceptions/leadership_not_found.exception.ts
│ ├── exceptions/leadership_duplicate_request.exception.ts
│ ├── exceptions/leadership_relation_exists.exception.ts
│ ├── exceptions/leadership_cross_org.exception.ts
│ ├── exceptions/leadership_self_request.exception.ts
│ ├── exceptions/leadership_forbidden.exception.ts
│ └── http/leadership_http.controller.ts
└── leadership.module.ts
src/language/es/leadership.json
src/language/en/leadership.json
src/language/pt/leadership.json
Modified files:
src/modules/role/domain/enums/permission_resource.enum.ts — add LEADERSHIP
src/modules/role/domain/enums/permission_action.enum.ts — add READ_ORGANIGRAMA
prisma/models/roles.prisma — add LEADERSHIP to PermissionResource enum, add READ_ORGANIGRAMA to PermissionAction enum
prisma/models/users.prisma — add leader_id, is_leader
src/modules/app.module.ts — register LeadershipModule
Task 1: Permissions — Add LEADERSHIP resource and READ_ORGANIGRAMA action
Files:
- Modify: src/modules/role/domain/enums/permission_resource.enum.ts
- Modify: src/modules/role/domain/enums/permission_action.enum.ts
- Modify: prisma/models/roles.prisma
- Create: prisma/migrations/20260411120000_add_leadership_permissions/migration.sql
- [ ] Step 1: Add LEADERSHIP to PermissionResource enum (TypeScript)
Replace the contents of src/modules/role/domain/enums/permission_resource.enum.ts:
export enum PermissionResource {
USER = 'USER',
ORGANIZATION = 'ORGANIZATION',
ROLE = 'ROLE',
LEADERSHIP = 'LEADERSHIP',
}
- [ ] Step 2: Add READ_ORGANIGRAMA to PermissionAction enum (TypeScript)
Replace the contents of src/modules/role/domain/enums/permission_action.enum.ts:
export enum PermissionAction {
READ = 'READ',
WRITE = 'WRITE',
READ_ORGANIGRAMA = 'READ_ORGANIGRAMA',
}
- [ ] Step 3: Update Prisma schema enums in roles.prisma
In prisma/models/roles.prisma, update the two enums:
enum PermissionResource {
USER
ORGANIZATION
ROLE
LEADERSHIP
}
enum PermissionAction {
READ
WRITE
READ_ORGANIGRAMA
}
- [ ] Step 4: Create migration SQL
Create prisma/migrations/20260411120000_add_leadership_permissions/migration.sql:
-- Add LEADERSHIP to PermissionResource enum
ALTER TYPE "PermissionResource" ADD VALUE 'LEADERSHIP';
-- Add READ_ORGANIGRAMA to PermissionAction enum
ALTER TYPE "PermissionAction" ADD VALUE 'READ_ORGANIGRAMA';
-- Insert LEADERSHIP:WRITE permission (for all users)
INSERT INTO "permissions" ("id", "resource", "action", "created_at")
VALUES (gen_random_uuid(), 'LEADERSHIP', 'WRITE', NOW())
ON CONFLICT ("resource", "action") DO NOTHING;
-- Insert LEADERSHIP:READ_ORGANIGRAMA permission (for specific roles)
INSERT INTO "permissions" ("id", "resource", "action", "created_at")
VALUES (gen_random_uuid(), 'LEADERSHIP', 'READ_ORGANIGRAMA', NOW())
ON CONFLICT ("resource", "action") DO NOTHING;
-- Assign LEADERSHIP:WRITE to all existing Administrador roles (is_system = true)
INSERT INTO "role_permissions" ("role_id", "permission_id")
SELECT r."id", p."id"
FROM "roles" r
CROSS JOIN "permissions" p
WHERE r."is_system" = true
AND p."resource" = 'LEADERSHIP'
AND p."action" = 'WRITE'
ON CONFLICT DO NOTHING;
-- Assign LEADERSHIP:READ_ORGANIGRAMA to all existing Administrador roles
INSERT INTO "role_permissions" ("role_id", "permission_id")
SELECT r."id", p."id"
FROM "roles" r
CROSS JOIN "permissions" p
WHERE r."is_system" = true
AND p."resource" = 'LEADERSHIP'
AND p."action" = 'READ_ORGANIGRAMA'
ON CONFLICT DO NOTHING;
- [ ] Step 5: Commit
git add src/modules/role/domain/enums/ prisma/models/roles.prisma prisma/migrations/20260411120000_add_leadership_permissions/
git commit -m "feat(leadership): add LEADERSHIP resource and READ_ORGANIGRAMA action to permissions catalog"
Task 2: Schema — Add leader_id and is_leader to Users
Files:
- Modify: prisma/models/users.prisma
- Create: prisma/migrations/20260411120001_add_leader_id_to_users/migration.sql
- [ ] Step 1: Add fields to Users prisma model
In prisma/models/users.prisma, add inside the Users model (after e_mode field):
leader_id String?
is_leader Boolean @default(false)
leader Users? @relation("UserLeadership", fields: [leader_id], references: [id], onDelete: SetNull)
followers Users[] @relation("UserLeadership")
- [ ] Step 2: Create migration SQL
Create prisma/migrations/20260411120001_add_leader_id_to_users/migration.sql:
ALTER TABLE "Users" ADD COLUMN "leader_id" TEXT;
ALTER TABLE "Users" ADD COLUMN "is_leader" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Users"
ADD CONSTRAINT "Users_leader_id_fkey"
FOREIGN KEY ("leader_id") REFERENCES "Users"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Users_leader_id_idx" ON "Users"("leader_id");
- [ ] Step 3: Regenerate Prisma client
- [ ] Step 4: Commit
git add prisma/models/users.prisma prisma/migrations/20260411120001_add_leader_id_to_users/
git commit -m "feat(leadership): add leader_id and is_leader fields to Users"
Task 3: Schema — Create leadership_requests table
Files:
- Create: prisma/models/leadership.prisma
- Create: prisma/migrations/20260411120002_create_leadership_requests/migration.sql
- [ ] Step 1: Create leadership.prisma model
Create prisma/models/leadership.prisma:
enum LeadershipRequestType {
REQUEST_LEADER // from_user wants to_user as their leader
INVITE_FOLLOWER // from_user invites to_user to be their follower
TRANSFER_LEADER // from_user wants to transfer to to_user as new leader
UNLINK_NOTIFICATION // activity entry: someone unlinked from to_user
@@map("LeadershipRequestType")
}
enum LeadershipRequestStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
@@map("LeadershipRequestStatus")
}
model LeadershipRequests {
id String @id @default(uuid())
from_user_id String
to_user_id String
organization_id String
type LeadershipRequestType
status LeadershipRequestStatus @default(PENDING)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
resolved_at DateTime?
from_user Users @relation("LeadershipRequestsFrom", fields: [from_user_id], references: [id], onDelete: Cascade)
to_user Users @relation("LeadershipRequestsTo", fields: [to_user_id], references: [id], onDelete: Cascade)
organization Organizations @relation(fields: [organization_id], references: [id], onDelete: Cascade)
@@index([from_user_id])
@@index([to_user_id])
@@index([organization_id])
@@index([status])
@@map("leadership_requests")
}
- [ ] Step 2: Add relations to Users model in users.prisma
In prisma/models/users.prisma, add inside the Users model relations section:
leadership_requests_sent LeadershipRequests[] @relation("LeadershipRequestsFrom")
leadership_requests_received LeadershipRequests[] @relation("LeadershipRequestsTo")
- [ ] Step 3: Create migration SQL
Create prisma/migrations/20260411120002_create_leadership_requests/migration.sql:
CREATE TYPE "LeadershipRequestType" AS ENUM (
'REQUEST_LEADER',
'INVITE_FOLLOWER',
'TRANSFER_LEADER',
'UNLINK_NOTIFICATION'
);
CREATE TYPE "LeadershipRequestStatus" AS ENUM (
'PENDING',
'ACCEPTED',
'REJECTED',
'CANCELLED'
);
CREATE TABLE "leadership_requests" (
"id" TEXT NOT NULL,
"from_user_id" TEXT NOT NULL,
"to_user_id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"type" "LeadershipRequestType" NOT NULL,
"status" "LeadershipRequestStatus" NOT NULL DEFAULT 'PENDING',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"resolved_at" TIMESTAMP(3),
CONSTRAINT "leadership_requests_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "leadership_requests_from_user_id_idx" ON "leadership_requests"("from_user_id");
CREATE INDEX "leadership_requests_to_user_id_idx" ON "leadership_requests"("to_user_id");
CREATE INDEX "leadership_requests_organization_id_idx" ON "leadership_requests"("organization_id");
CREATE INDEX "leadership_requests_status_idx" ON "leadership_requests"("status");
ALTER TABLE "leadership_requests"
ADD CONSTRAINT "leadership_requests_from_user_id_fkey"
FOREIGN KEY ("from_user_id") REFERENCES "Users"("id") ON DELETE CASCADE;
ALTER TABLE "leadership_requests"
ADD CONSTRAINT "leadership_requests_to_user_id_fkey"
FOREIGN KEY ("to_user_id") REFERENCES "Users"("id") ON DELETE CASCADE;
ALTER TABLE "leadership_requests"
ADD CONSTRAINT "leadership_requests_organization_id_fkey"
FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
- [ ] Step 4: Regenerate Prisma client
leadershipRequests model.
- [ ] Step 5: Commit
git add prisma/models/leadership.prisma prisma/models/users.prisma prisma/migrations/20260411120002_create_leadership_requests/
git commit -m "feat(leadership): create leadership_requests table with types and status enums"
Task 4: Domain — Enums, entity and exceptions
Files:
- Create: src/modules/leadership/domain/enums/leadership_request_type.enum.ts
- Create: src/modules/leadership/domain/enums/leadership_request_status.enum.ts
- Create: src/modules/leadership/domain/entities/leadership_request.entity.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_not_found.exception.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_duplicate_request.exception.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_relation_exists.exception.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_cross_org.exception.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_self_request.exception.ts
- Create: src/modules/leadership/infrastructure/exceptions/leadership_forbidden.exception.ts
- Create: src/language/es/leadership.json
- Create: src/language/en/leadership.json
- Create: src/language/pt/leadership.json
- [ ] Step 1: Create request type enum
Create src/modules/leadership/domain/enums/leadership_request_type.enum.ts:
export enum LeadershipRequestType {
REQUEST_LEADER = 'REQUEST_LEADER',
INVITE_FOLLOWER = 'INVITE_FOLLOWER',
TRANSFER_LEADER = 'TRANSFER_LEADER',
UNLINK_NOTIFICATION = 'UNLINK_NOTIFICATION',
}
- [ ] Step 2: Create request status enum
Create src/modules/leadership/domain/enums/leadership_request_status.enum.ts:
export enum LeadershipRequestStatus {
PENDING = 'PENDING',
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
CANCELLED = 'CANCELLED',
}
- [ ] Step 3: Create entity
Create src/modules/leadership/domain/entities/leadership_request.entity.ts:
import { LeadershipRequestType } from '../enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '../enums/leadership_request_status.enum';
export class LeadershipRequest {
constructor(
public readonly id: string,
public readonly fromUserId: string,
public readonly toUserId: string,
public readonly organizationId: string,
public readonly type: LeadershipRequestType,
public status: LeadershipRequestStatus,
public readonly createdAt: Date,
public resolvedAt: Date | null,
) {}
accept(): void {
this.status = LeadershipRequestStatus.ACCEPTED;
this.resolvedAt = new Date();
}
reject(): void {
this.status = LeadershipRequestStatus.REJECTED;
this.resolvedAt = new Date();
}
cancel(): void {
this.status = LeadershipRequestStatus.CANCELLED;
this.resolvedAt = new Date();
}
isPending(): boolean {
return this.status === LeadershipRequestStatus.PENDING;
}
}
- [ ] Step 4: Create i18n files
Create src/language/es/leadership.json:
{
"request_not_found": "Solicitud no encontrada",
"duplicate_request": "Ya existe una solicitud pendiente con esta persona",
"relation_exists": "Ya existe una relación de liderazgo activa con esta persona",
"cross_org": "No se puede establecer una relación con una persona de otra organización",
"self_request": "No puedes enviarte una solicitud a ti mismo",
"forbidden": "No tienes permiso para realizar esta acción",
"already_has_leader": "Ya tienes un líder asignado. Usa la transferencia para cambiar de líder"
}
Create src/language/en/leadership.json:
{
"request_not_found": "Request not found",
"duplicate_request": "A pending request already exists with this person",
"relation_exists": "An active leadership relationship already exists with this person",
"cross_org": "Cannot establish a relationship with a person from another organization",
"self_request": "You cannot send a request to yourself",
"forbidden": "You do not have permission to perform this action",
"already_has_leader": "You already have a leader assigned. Use transfer to change your leader"
}
Create src/language/pt/leadership.json:
{
"request_not_found": "Solicitação não encontrada",
"duplicate_request": "Já existe uma solicitação pendente com esta pessoa",
"relation_exists": "Já existe um relacionamento de liderança ativo com esta pessoa",
"cross_org": "Não é possível estabelecer um relacionamento com uma pessoa de outra organização",
"self_request": "Você não pode enviar uma solicitação para si mesmo",
"forbidden": "Você não tem permissão para realizar esta ação",
"already_has_leader": "Você já tem um líder atribuído. Use a transferência para mudar de líder"
}
- [ ] Step 5: Create exceptions
Create src/modules/leadership/infrastructure/exceptions/leadership_not_found.exception.ts:
import { NotFoundException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipNotFoundException extends NotFoundException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.request_not_found'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_duplicate_request.exception.ts:
import { ConflictException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipDuplicateRequestException extends ConflictException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.duplicate_request'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_relation_exists.exception.ts:
import { ConflictException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipRelationExistsException extends ConflictException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.relation_exists'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_cross_org.exception.ts:
import { BadRequestException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipCrossOrgException extends BadRequestException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.cross_org'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_self_request.exception.ts:
import { BadRequestException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipSelfRequestException extends BadRequestException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.self_request'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_forbidden.exception.ts:
import { ForbiddenException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipForbiddenException extends ForbiddenException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.forbidden'));
}
}
Create src/modules/leadership/infrastructure/exceptions/leadership_already_has_leader.exception.ts:
import { ConflictException } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
export class LeadershipAlreadyHasLeaderException extends ConflictException {
constructor(i18n: I18nService) {
super(i18n.t('leadership.already_has_leader'));
}
}
- [ ] Step 6: Commit
git add src/modules/leadership/domain/ src/modules/leadership/infrastructure/exceptions/ src/language/
git commit -m "feat(leadership): add domain enums, entity, exceptions and i18n messages"
Task 5: Repository — Interface and Prisma implementation
Files:
- Create: src/modules/leadership/infrastructure/db/leadership_request.repository.ts
- Create: src/modules/leadership/infrastructure/db/leadership_request.prisma.ts
- [ ] Step 1: Create repository interface
Create src/modules/leadership/infrastructure/db/leadership_request.repository.ts:
import type { LeadershipRequest } from '@Leadership/domain/entities/leadership_request.entity';
import type { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import type { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
export const LEADERSHIP_REPOSITORY = 'LEADERSHIP_REPOSITORY';
export interface CreateLeadershipRequestData {
id: string;
fromUserId: string;
toUserId: string;
organizationId: string;
type: LeadershipRequestType;
status: LeadershipRequestStatus;
resolvedAt?: Date;
}
export interface ILeadershipRepository {
save(request: LeadershipRequest): Promise<void>;
create(data: CreateLeadershipRequestData): Promise<void>;
findById(id: string): Promise<LeadershipRequest | null>;
findPendingBetween(userAId: string, userBId: string): Promise<LeadershipRequest | null>;
findByFromAndTo(fromUserId: string, toUserId: string): Promise<LeadershipRequest[]>;
listForUser(userId: string): Promise<LeadershipRequest[]>;
updateUserLeader(userId: string, leaderId: string | null): Promise<void>;
updateIsLeader(userId: string, isLeader: boolean): Promise<void>;
countFollowers(userId: string): Promise<number>;
getUserOrgId(userId: string): Promise<string | null>;
getUserLeaderId(userId: string): Promise<string | null>;
getOrgTree(organizationId: string): Promise<OrgTreeNode[]>;
searchUsersInOrg(organizationId: string, query: string): Promise<UserSearchResult[]>;
}
export interface OrgTreeNode {
id: string;
name: string;
leaderId: string | null;
}
export interface UserSearchResult {
id: string;
firstName: string;
lastName: string;
email: string;
}
- [ ] Step 2: Create Prisma implementation
Create src/modules/leadership/infrastructure/db/leadership_request.prisma.ts:
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '@config/db/database.service';
import type {
ILeadershipRepository,
CreateLeadershipRequestData,
OrgTreeNode,
UserSearchResult,
} from './leadership_request.repository';
import { LeadershipRequest } from '@Leadership/domain/entities/leadership_request.entity';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
@Injectable()
export class LeadershipPrismaRepository implements ILeadershipRepository {
constructor(private readonly db: DatabaseService) {}
async save(request: LeadershipRequest): Promise<void> {
await this.db.leadershipRequests.update({
where: { id: request.id },
data: {
status: request.status,
resolved_at: request.resolvedAt,
updated_at: new Date(),
},
});
}
async create(data: CreateLeadershipRequestData): Promise<void> {
await this.db.leadershipRequests.create({
data: {
id: data.id,
from_user_id: data.fromUserId,
to_user_id: data.toUserId,
organization_id: data.organizationId,
type: data.type,
status: data.status,
resolved_at: data.resolvedAt ?? null,
updated_at: new Date(),
},
});
}
async findById(id: string): Promise<LeadershipRequest | null> {
const record = await this.db.leadershipRequests.findUnique({ where: { id } });
if (!record) return null;
return this.toDomain(record);
}
async findPendingBetween(userAId: string, userBId: string): Promise<LeadershipRequest | null> {
const record = await this.db.leadershipRequests.findFirst({
where: {
status: 'PENDING',
OR: [
{ from_user_id: userAId, to_user_id: userBId },
{ from_user_id: userBId, to_user_id: userAId },
],
},
});
if (!record) return null;
return this.toDomain(record);
}
async findByFromAndTo(fromUserId: string, toUserId: string): Promise<LeadershipRequest[]> {
const records = await this.db.leadershipRequests.findMany({
where: { from_user_id: fromUserId, to_user_id: toUserId },
});
return records.map((r) => this.toDomain(r));
}
async listForUser(userId: string): Promise<LeadershipRequest[]> {
const records = await this.db.leadershipRequests.findMany({
where: {
OR: [{ from_user_id: userId }, { to_user_id: userId }],
type: { not: 'UNLINK_NOTIFICATION' },
},
orderBy: { created_at: 'desc' },
});
return records.map((r) => this.toDomain(r));
}
async updateUserLeader(userId: string, leaderId: string | null): Promise<void> {
await this.db.users.update({
where: { id: userId },
data: { leader_id: leaderId },
});
}
async updateIsLeader(userId: string, isLeader: boolean): Promise<void> {
await this.db.users.update({
where: { id: userId },
data: { is_leader: isLeader },
});
}
async countFollowers(userId: string): Promise<number> {
return this.db.users.count({ where: { leader_id: userId } });
}
async getUserOrgId(userId: string): Promise<string | null> {
const user = await this.db.users.findUnique({
where: { id: userId },
select: { organization_id: true },
});
return user?.organization_id ?? null;
}
async getUserLeaderId(userId: string): Promise<string | null> {
const user = await this.db.users.findUnique({
where: { id: userId },
select: { leader_id: true },
});
return user?.leader_id ?? null;
}
async getOrgTree(organizationId: string): Promise<OrgTreeNode[]> {
const users = await this.db.users.findMany({
where: { organization_id: organizationId, deleted_at: null, active: true },
select: { id: true, first_name: true, last_name: true, leader_id: true },
});
return users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
leaderId: u.leader_id,
}));
}
async searchUsersInOrg(organizationId: string, query: string): Promise<UserSearchResult[]> {
const users = await this.db.users.findMany({
where: {
organization_id: organizationId,
deleted_at: null,
active: true,
OR: [
{ first_name: { contains: query, mode: 'insensitive' } },
{ last_name: { contains: query, mode: 'insensitive' } },
{ email: { contains: query, mode: 'insensitive' } },
],
},
select: { id: true, first_name: true, last_name: true, email: true },
take: 20,
});
return users.map((u) => ({
id: u.id,
firstName: u.first_name,
lastName: u.last_name,
email: u.email,
}));
}
private toDomain(record: {
id: string;
from_user_id: string;
to_user_id: string;
organization_id: string;
type: string;
status: string;
created_at: Date;
resolved_at: Date | null;
}): LeadershipRequest {
return new LeadershipRequest(
record.id,
record.from_user_id,
record.to_user_id,
record.organization_id,
record.type as LeadershipRequestType,
record.status as LeadershipRequestStatus,
record.created_at,
record.resolved_at,
);
}
}
- [ ] Step 3: Commit
git add src/modules/leadership/infrastructure/db/
git commit -m "feat(leadership): add repository interface and Prisma implementation"
Task 6: DTOs
Files:
- Create: src/modules/leadership/application/dtos/leadership_request.dto.ts
- Create: src/modules/leadership/application/dtos/leadership_user_search.dto.ts
- Create: src/modules/leadership/application/dtos/organigrama.dto.ts
- [ ] Step 1: Create request DTO
Create src/modules/leadership/application/dtos/leadership_request.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
export class SendLeadershipRequestDTO {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
@IsUUID()
@IsNotEmpty()
targetUserId: string;
@ApiProperty({ enum: [LeadershipRequestType.REQUEST_LEADER, LeadershipRequestType.INVITE_FOLLOWER] })
@IsEnum([LeadershipRequestType.REQUEST_LEADER, LeadershipRequestType.INVITE_FOLLOWER])
type: LeadershipRequestType.REQUEST_LEADER | LeadershipRequestType.INVITE_FOLLOWER;
}
export class ResolveLeadershipRequestDTO {
@ApiProperty({ enum: ['ACCEPTED', 'REJECTED'] })
@IsEnum(['ACCEPTED', 'REJECTED'])
action: 'ACCEPTED' | 'REJECTED';
}
export class TransferLeadershipDTO {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440002' })
@IsUUID()
@IsNotEmpty()
newLeaderId: string;
}
export class UnlinkLeadershipDTO {
@ApiProperty({ enum: ['leader', 'follower'] })
@IsEnum(['leader', 'follower'])
role: 'leader' | 'follower';
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440003', required: false })
@IsUUID()
followerId?: string;
}
export class LeadershipRequestResponseDTO {
@ApiProperty()
id: string;
@ApiProperty()
fromUserId: string;
@ApiProperty()
toUserId: string;
@ApiProperty({ enum: LeadershipRequestType })
type: LeadershipRequestType;
@ApiProperty({ enum: LeadershipRequestStatus })
status: LeadershipRequestStatus;
@ApiProperty()
createdAt: string;
@ApiProperty({ nullable: true })
resolvedAt: string | null;
@ApiProperty({ enum: ['incoming', 'outgoing'] })
direction: 'incoming' | 'outgoing';
}
- [ ] Step 2: Create user search DTO
Create src/modules/leadership/application/dtos/leadership_user_search.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class SearchLeadershipUsersDTO {
@ApiProperty({ example: 'María' })
@IsString()
@IsNotEmpty()
@MinLength(2)
q: string;
}
export class LeadershipUserResultDTO {
@ApiProperty()
id: string;
@ApiProperty()
firstName: string;
@ApiProperty()
lastName: string;
@ApiProperty()
email: string;
}
- [ ] Step 3: Create organigrama DTO
Create src/modules/leadership/application/dtos/organigrama.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
export class OrganigramaNodeDTO {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty({ type: () => [OrganigramaNodeDTO] })
subordinates: OrganigramaNodeDTO[];
}
export class OrganigramaDTO {
@ApiProperty({ type: [OrganigramaNodeDTO] })
roots: OrganigramaNodeDTO[];
@ApiProperty({ type: [OrganigramaNodeDTO] })
unassigned: OrganigramaNodeDTO[];
}
- [ ] Step 4: Commit
git add src/modules/leadership/application/dtos/
git commit -m "feat(leadership): add DTOs for requests, user search and organigrama"
Task 7: Send request command
Files:
- Create: src/modules/leadership/application/commands/send_request/send_leadership_request.command.ts
- Create: src/modules/leadership/application/commands/send_request/send_leadership_request.handler.ts
- Create: src/modules/leadership/application/commands/send_request/send_leadership_request.handler.spec.ts
- [ ] Step 1: Write the failing test
Create src/modules/leadership/application/commands/send_request/send_leadership_request.handler.spec.ts:
jest.mock('@prisma/generated', () => ({
PrismaClient: class PrismaClient {},
}));
import { Test } from '@nestjs/testing';
import { I18nService } from 'nestjs-i18n';
import { SendLeadershipRequestHandler } from './send_leadership_request.handler';
import { SendLeadershipRequestCommand } from './send_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipDuplicateRequestException } from '@Leadership/infrastructure/exceptions/leadership_duplicate_request.exception';
import { LeadershipSelfRequestException } from '@Leadership/infrastructure/exceptions/leadership_self_request.exception';
import { LeadershipCrossOrgException } from '@Leadership/infrastructure/exceptions/leadership_cross_org.exception';
import { LeadershipRelationExistsException } from '@Leadership/infrastructure/exceptions/leadership_relation_exists.exception';
import { LeadershipAlreadyHasLeaderException } from '@Leadership/infrastructure/exceptions/leadership_already_has_leader.exception';
describe('SendLeadershipRequestHandler', () => {
const userId = 'user-001';
const targetId = 'user-002';
const orgId = 'org-001';
let handler: SendLeadershipRequestHandler;
let repo: jest.Mocked<Pick<ILeadershipRepository,
'getUserOrgId' | 'findPendingBetween' | 'findByFromAndTo' | 'getUserLeaderId' | 'create'
>>;
beforeEach(async () => {
repo = {
getUserOrgId: jest.fn().mockResolvedValue(orgId),
findPendingBetween: jest.fn().mockResolvedValue(null),
findByFromAndTo: jest.fn().mockResolvedValue([]),
getUserLeaderId: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({
providers: [
SendLeadershipRequestHandler,
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue('msg') } },
{ provide: LEADERSHIP_REPOSITORY, useValue: repo },
],
}).compile();
handler = module.get(SendLeadershipRequestHandler);
});
it('should create a REQUEST_LEADER request on happy path', async () => {
await handler.execute(
new SendLeadershipRequestCommand(userId, orgId, targetId, LeadershipRequestType.REQUEST_LEADER),
);
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
fromUserId: userId,
toUserId: targetId,
organizationId: orgId,
type: LeadershipRequestType.REQUEST_LEADER,
}));
});
it('should throw LeadershipSelfRequestException when sending to self', async () => {
await expect(
handler.execute(new SendLeadershipRequestCommand(userId, orgId, userId, LeadershipRequestType.REQUEST_LEADER)),
).rejects.toThrow(LeadershipSelfRequestException);
expect(repo.create).not.toHaveBeenCalled();
});
it('should throw LeadershipCrossOrgException when target is in different org', async () => {
repo.getUserOrgId.mockImplementation((id) =>
Promise.resolve(id === userId ? orgId : 'other-org'),
);
await expect(
handler.execute(new SendLeadershipRequestCommand(userId, orgId, targetId, LeadershipRequestType.REQUEST_LEADER)),
).rejects.toThrow(LeadershipCrossOrgException);
});
it('should throw LeadershipDuplicateRequestException when pending request exists', async () => {
repo.findPendingBetween.mockResolvedValue({ id: 'req-001' } as never);
await expect(
handler.execute(new SendLeadershipRequestCommand(userId, orgId, targetId, LeadershipRequestType.REQUEST_LEADER)),
).rejects.toThrow(LeadershipDuplicateRequestException);
});
it('should throw LeadershipAlreadyHasLeaderException when REQUEST_LEADER and user already has leader', async () => {
repo.getUserLeaderId.mockResolvedValue('some-leader-id');
await expect(
handler.execute(new SendLeadershipRequestCommand(userId, orgId, targetId, LeadershipRequestType.REQUEST_LEADER)),
).rejects.toThrow(LeadershipAlreadyHasLeaderException);
});
});
- [ ] Step 2: Run test to verify it fails
- [ ] Step 3: Create command
Create src/modules/leadership/application/commands/send_request/send_leadership_request.command.ts:
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
export class SendLeadershipRequestCommand {
constructor(
public readonly fromUserId: string,
public readonly organizationId: string,
public readonly toUserId: string,
public readonly type: LeadershipRequestType.REQUEST_LEADER | LeadershipRequestType.INVITE_FOLLOWER,
) {}
}
- [ ] Step 4: Create handler
Create src/modules/leadership/application/commands/send_request/send_leadership_request.handler.ts:
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { randomUUID } from 'node:crypto';
import { SendLeadershipRequestCommand } from './send_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipSelfRequestException } from '@Leadership/infrastructure/exceptions/leadership_self_request.exception';
import { LeadershipCrossOrgException } from '@Leadership/infrastructure/exceptions/leadership_cross_org.exception';
import { LeadershipDuplicateRequestException } from '@Leadership/infrastructure/exceptions/leadership_duplicate_request.exception';
import { LeadershipAlreadyHasLeaderException } from '@Leadership/infrastructure/exceptions/leadership_already_has_leader.exception';
@CommandHandler(SendLeadershipRequestCommand)
export class SendLeadershipRequestHandler
implements ICommandHandler<SendLeadershipRequestCommand, void>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
private readonly i18n: I18nService,
) {}
async execute(command: SendLeadershipRequestCommand): Promise<void> {
const { fromUserId, organizationId, toUserId, type } = command;
if (fromUserId === toUserId) {
throw new LeadershipSelfRequestException(this.i18n);
}
const targetOrgId = await this.repo.getUserOrgId(toUserId);
if (targetOrgId !== organizationId) {
throw new LeadershipCrossOrgException(this.i18n);
}
const pending = await this.repo.findPendingBetween(fromUserId, toUserId);
if (pending) {
throw new LeadershipDuplicateRequestException(this.i18n);
}
if (type === LeadershipRequestType.REQUEST_LEADER) {
const currentLeaderId = await this.repo.getUserLeaderId(fromUserId);
if (currentLeaderId) {
throw new LeadershipAlreadyHasLeaderException(this.i18n);
}
}
await this.repo.create({
id: randomUUID(),
fromUserId,
toUserId,
organizationId,
type,
status: LeadershipRequestStatus.PENDING,
});
}
}
- [ ] Step 5: Run test to verify it passes
- [ ] Step 6: Commit
git add src/modules/leadership/application/commands/send_request/
git commit -m "feat(leadership): add send leadership request command with validation"
Task 8: Resolve request command (accept/reject)
Files:
- Create: src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.command.ts
- Create: src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.handler.ts
- Create: src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.handler.spec.ts
- [ ] Step 1: Write the failing test
Create src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.handler.spec.ts:
jest.mock('@prisma/generated', () => ({
PrismaClient: class PrismaClient {},
}));
import { Test } from '@nestjs/testing';
import { I18nService } from 'nestjs-i18n';
import { ResolveLeadershipRequestHandler } from './resolve_leadership_request.handler';
import { ResolveLeadershipRequestCommand } from './resolve_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequest } from '@Leadership/domain/entities/leadership_request.entity';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
import { LeadershipForbiddenException } from '@Leadership/infrastructure/exceptions/leadership_forbidden.exception';
const makeRequest = (overrides: Partial<{
id: string; fromUserId: string; toUserId: string;
type: LeadershipRequestType; status: LeadershipRequestStatus;
}> = {}) =>
new LeadershipRequest(
overrides.id ?? 'req-001',
overrides.fromUserId ?? 'user-001',
overrides.toUserId ?? 'user-002',
'org-001',
overrides.type ?? LeadershipRequestType.REQUEST_LEADER,
overrides.status ?? LeadershipRequestStatus.PENDING,
new Date(),
null,
);
describe('ResolveLeadershipRequestHandler', () => {
let handler: ResolveLeadershipRequestHandler;
let repo: jest.Mocked<Pick<ILeadershipRepository,
'findById' | 'save' | 'updateUserLeader' | 'updateIsLeader' | 'getUserLeaderId' | 'countFollowers' | 'create'
>>;
beforeEach(async () => {
repo = {
findById: jest.fn().mockResolvedValue(makeRequest()),
save: jest.fn().mockResolvedValue(undefined),
updateUserLeader: jest.fn().mockResolvedValue(undefined),
updateIsLeader: jest.fn().mockResolvedValue(undefined),
getUserLeaderId: jest.fn().mockResolvedValue(null),
countFollowers: jest.fn().mockResolvedValue(0),
create: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({
providers: [
ResolveLeadershipRequestHandler,
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue('msg') } },
{ provide: LEADERSHIP_REPOSITORY, useValue: repo },
],
}).compile();
handler = module.get(ResolveLeadershipRequestHandler);
});
it('should accept REQUEST_LEADER: set leader_id on requester and is_leader on target', async () => {
await handler.execute(new ResolveLeadershipRequestCommand('req-001', 'user-002', 'ACCEPTED'));
expect(repo.updateUserLeader).toHaveBeenCalledWith('user-001', 'user-002');
expect(repo.updateIsLeader).toHaveBeenCalledWith('user-002', true);
expect(repo.save).toHaveBeenCalled();
});
it('should accept INVITE_FOLLOWER: set leader_id on invited user', async () => {
repo.findById.mockResolvedValue(
makeRequest({ type: LeadershipRequestType.INVITE_FOLLOWER, fromUserId: 'user-002', toUserId: 'user-001' }),
);
await handler.execute(new ResolveLeadershipRequestCommand('req-001', 'user-001', 'ACCEPTED'));
expect(repo.updateUserLeader).toHaveBeenCalledWith('user-001', 'user-002');
expect(repo.updateIsLeader).toHaveBeenCalledWith('user-002', true);
});
it('should reject request: only update status, no user changes', async () => {
await handler.execute(new ResolveLeadershipRequestCommand('req-001', 'user-002', 'REJECTED'));
expect(repo.updateUserLeader).not.toHaveBeenCalled();
expect(repo.updateIsLeader).not.toHaveBeenCalled();
expect(repo.save).toHaveBeenCalled();
});
it('should throw LeadershipNotFoundException when request not found', async () => {
repo.findById.mockResolvedValue(null);
await expect(
handler.execute(new ResolveLeadershipRequestCommand('bad-id', 'user-002', 'ACCEPTED')),
).rejects.toThrow(LeadershipNotFoundException);
});
it('should throw LeadershipForbiddenException when user is not the recipient', async () => {
await expect(
handler.execute(new ResolveLeadershipRequestCommand('req-001', 'wrong-user', 'ACCEPTED')),
).rejects.toThrow(LeadershipForbiddenException);
});
});
- [ ] Step 2: Run test to verify it fails
- [ ] Step 3: Create command
Create src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.command.ts:
export class ResolveLeadershipRequestCommand {
constructor(
public readonly requestId: string,
public readonly resolverUserId: string,
public readonly action: 'ACCEPTED' | 'REJECTED',
) {}
}
- [ ] Step 4: Create handler
Create src/modules/leadership/application/commands/resolve_request/resolve_leadership_request.handler.ts:
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { randomUUID } from 'node:crypto';
import { ResolveLeadershipRequestCommand } from './resolve_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
import { LeadershipForbiddenException } from '@Leadership/infrastructure/exceptions/leadership_forbidden.exception';
@CommandHandler(ResolveLeadershipRequestCommand)
export class ResolveLeadershipRequestHandler
implements ICommandHandler<ResolveLeadershipRequestCommand, void>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
private readonly i18n: I18nService,
) {}
async execute(command: ResolveLeadershipRequestCommand): Promise<void> {
const { requestId, resolverUserId, action } = command;
const request = await this.repo.findById(requestId);
if (!request) throw new LeadershipNotFoundException(this.i18n);
if (request.toUserId !== resolverUserId) throw new LeadershipForbiddenException(this.i18n);
if (action === 'ACCEPTED') {
request.accept();
await this.applyAcceptance(request);
} else {
request.reject();
}
await this.repo.save(request);
}
private async applyAcceptance(request: InstanceType<typeof import('@Leadership/domain/entities/leadership_request.entity').LeadershipRequest>): Promise<void> {
const { type, fromUserId, toUserId, organizationId } = request;
if (type === LeadershipRequestType.REQUEST_LEADER) {
// fromUser wants toUser as their leader
await this.repo.updateUserLeader(fromUserId, toUserId);
await this.repo.updateIsLeader(toUserId, true);
} else if (type === LeadershipRequestType.INVITE_FOLLOWER) {
// fromUser (leader) invited toUser as follower
await this.repo.updateUserLeader(toUserId, fromUserId);
await this.repo.updateIsLeader(fromUserId, true);
} else if (type === LeadershipRequestType.TRANSFER_LEADER) {
// fromUser wants to transfer to toUser as new leader
const oldLeaderId = await this.repo.getUserLeaderId(fromUserId);
await this.repo.updateUserLeader(fromUserId, toUserId);
await this.repo.updateIsLeader(toUserId, true);
if (oldLeaderId) {
const remainingFollowers = await this.repo.countFollowers(oldLeaderId);
if (remainingFollowers === 0) {
await this.repo.updateIsLeader(oldLeaderId, false);
}
await this.repo.create({
id: randomUUID(),
fromUserId,
toUserId: oldLeaderId,
organizationId,
type: LeadershipRequestType.UNLINK_NOTIFICATION,
status: LeadershipRequestStatus.ACCEPTED,
resolvedAt: new Date(),
});
}
}
}
}
- [ ] Step 5: Run test to verify it passes
- [ ] Step 6: Commit
git add src/modules/leadership/application/commands/resolve_request/
git commit -m "feat(leadership): add resolve request command (accept/reject) with relationship wiring"
Task 9: Cancel request command
Files:
- Create: src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.command.ts
- Create: src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.handler.ts
- Create: src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.handler.spec.ts
- [ ] Step 1: Write the failing test
Create src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.handler.spec.ts:
jest.mock('@prisma/generated', () => ({
PrismaClient: class PrismaClient {},
}));
import { Test } from '@nestjs/testing';
import { I18nService } from 'nestjs-i18n';
import { CancelLeadershipRequestHandler } from './cancel_leadership_request.handler';
import { CancelLeadershipRequestCommand } from './cancel_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequest } from '@Leadership/domain/entities/leadership_request.entity';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
import { LeadershipForbiddenException } from '@Leadership/infrastructure/exceptions/leadership_forbidden.exception';
const makePendingRequest = () =>
new LeadershipRequest(
'req-001', 'user-001', 'user-002', 'org-001',
LeadershipRequestType.REQUEST_LEADER,
LeadershipRequestStatus.PENDING,
new Date(), null,
);
describe('CancelLeadershipRequestHandler', () => {
let handler: CancelLeadershipRequestHandler;
let repo: jest.Mocked<Pick<ILeadershipRepository, 'findById' | 'save'>>;
beforeEach(async () => {
repo = {
findById: jest.fn().mockResolvedValue(makePendingRequest()),
save: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({
providers: [
CancelLeadershipRequestHandler,
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue('msg') } },
{ provide: LEADERSHIP_REPOSITORY, useValue: repo },
],
}).compile();
handler = module.get(CancelLeadershipRequestHandler);
});
it('should cancel a pending request sent by the user', async () => {
await handler.execute(new CancelLeadershipRequestCommand('req-001', 'user-001'));
expect(repo.save).toHaveBeenCalled();
});
it('should throw LeadershipNotFoundException when request not found', async () => {
repo.findById.mockResolvedValue(null);
await expect(
handler.execute(new CancelLeadershipRequestCommand('bad-id', 'user-001')),
).rejects.toThrow(LeadershipNotFoundException);
});
it('should throw LeadershipForbiddenException when user did not send the request', async () => {
await expect(
handler.execute(new CancelLeadershipRequestCommand('req-001', 'other-user')),
).rejects.toThrow(LeadershipForbiddenException);
});
it('should throw LeadershipForbiddenException when request is not pending', async () => {
repo.findById.mockResolvedValue(
new LeadershipRequest('req-001', 'user-001', 'user-002', 'org-001',
LeadershipRequestType.REQUEST_LEADER, LeadershipRequestStatus.ACCEPTED, new Date(), new Date()),
);
await expect(
handler.execute(new CancelLeadershipRequestCommand('req-001', 'user-001')),
).rejects.toThrow(LeadershipForbiddenException);
});
});
- [ ] Step 2: Run test to verify it fails
- [ ] Step 3: Create command
Create src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.command.ts:
export class CancelLeadershipRequestCommand {
constructor(
public readonly requestId: string,
public readonly cancellerUserId: string,
) {}
}
- [ ] Step 4: Create handler
Create src/modules/leadership/application/commands/cancel_request/cancel_leadership_request.handler.ts:
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { CancelLeadershipRequestCommand } from './cancel_leadership_request.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
import { LeadershipForbiddenException } from '@Leadership/infrastructure/exceptions/leadership_forbidden.exception';
@CommandHandler(CancelLeadershipRequestCommand)
export class CancelLeadershipRequestHandler
implements ICommandHandler<CancelLeadershipRequestCommand, void>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
private readonly i18n: I18nService,
) {}
async execute(command: CancelLeadershipRequestCommand): Promise<void> {
const { requestId, cancellerUserId } = command;
const request = await this.repo.findById(requestId);
if (!request) throw new LeadershipNotFoundException(this.i18n);
if (request.fromUserId !== cancellerUserId) throw new LeadershipForbiddenException(this.i18n);
if (!request.isPending()) throw new LeadershipForbiddenException(this.i18n);
request.cancel();
await this.repo.save(request);
}
}
- [ ] Step 5: Run test to verify it passes
- [ ] Step 6: Commit
git add src/modules/leadership/application/commands/cancel_request/
git commit -m "feat(leadership): add cancel request command"
Task 10: Unlink command
Files:
- Create: src/modules/leadership/application/commands/unlink/unlink_leadership.command.ts
- Create: src/modules/leadership/application/commands/unlink/unlink_leadership.handler.ts
- Create: src/modules/leadership/application/commands/unlink/unlink_leadership.handler.spec.ts
- [ ] Step 1: Write the failing test
Create src/modules/leadership/application/commands/unlink/unlink_leadership.handler.spec.ts:
jest.mock('@prisma/generated', () => ({
PrismaClient: class PrismaClient {},
}));
import { Test } from '@nestjs/testing';
import { I18nService } from 'nestjs-i18n';
import { UnlinkLeadershipHandler } from './unlink_leadership.handler';
import { UnlinkLeadershipCommand } from './unlink_leadership.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
describe('UnlinkLeadershipHandler', () => {
let handler: UnlinkLeadershipHandler;
let repo: jest.Mocked<Pick<ILeadershipRepository,
'getUserLeaderId' | 'updateUserLeader' | 'countFollowers' | 'updateIsLeader' | 'create' | 'getUserOrgId'
>>;
beforeEach(async () => {
repo = {
getUserLeaderId: jest.fn().mockResolvedValue('leader-001'),
updateUserLeader: jest.fn().mockResolvedValue(undefined),
countFollowers: jest.fn().mockResolvedValue(0),
updateIsLeader: jest.fn().mockResolvedValue(undefined),
create: jest.fn().mockResolvedValue(undefined),
getUserOrgId: jest.fn().mockResolvedValue('org-001'),
};
const module = await Test.createTestingModule({
providers: [
UnlinkLeadershipHandler,
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue('msg') } },
{ provide: LEADERSHIP_REPOSITORY, useValue: repo },
],
}).compile();
handler = module.get(UnlinkLeadershipHandler);
});
it('should unlink leader, notify ex-leader and clear is_leader if no followers remain', async () => {
await handler.execute(new UnlinkLeadershipCommand('user-001', 'org-001', 'remove_my_leader'));
expect(repo.updateUserLeader).toHaveBeenCalledWith('user-001', null);
expect(repo.updateIsLeader).toHaveBeenCalledWith('leader-001', false);
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
toUserId: 'leader-001',
}));
});
it('should not clear is_leader if ex-leader still has other followers', async () => {
repo.countFollowers.mockResolvedValue(2);
await handler.execute(new UnlinkLeadershipCommand('user-001', 'org-001', 'remove_my_leader'));
expect(repo.updateIsLeader).not.toHaveBeenCalled();
});
it('should throw LeadershipNotFoundException when user has no leader to remove', async () => {
repo.getUserLeaderId.mockResolvedValue(null);
await expect(
handler.execute(new UnlinkLeadershipCommand('user-001', 'org-001', 'remove_my_leader')),
).rejects.toThrow(LeadershipNotFoundException);
});
});
- [ ] Step 2: Run test to verify it fails
- [ ] Step 3: Create command
Create src/modules/leadership/application/commands/unlink/unlink_leadership.command.ts:
export type UnlinkLeadershipRole = 'remove_my_leader' | 'remove_follower';
export class UnlinkLeadershipCommand {
constructor(
public readonly userId: string,
public readonly organizationId: string,
public readonly role: UnlinkLeadershipRole,
public readonly followerIdToRemove?: string,
) {}
}
- [ ] Step 4: Create handler
Create src/modules/leadership/application/commands/unlink/unlink_leadership.handler.ts:
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { randomUUID } from 'node:crypto';
import { UnlinkLeadershipCommand } from './unlink_leadership.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
@CommandHandler(UnlinkLeadershipCommand)
export class UnlinkLeadershipHandler
implements ICommandHandler<UnlinkLeadershipCommand, void>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
private readonly i18n: I18nService,
) {}
async execute(command: UnlinkLeadershipCommand): Promise<void> {
const { userId, organizationId, role, followerIdToRemove } = command;
if (role === 'remove_my_leader') {
const leaderId = await this.repo.getUserLeaderId(userId);
if (!leaderId) throw new LeadershipNotFoundException(this.i18n);
await this.repo.updateUserLeader(userId, null);
const remaining = await this.repo.countFollowers(leaderId);
if (remaining === 0) {
await this.repo.updateIsLeader(leaderId, false);
}
await this.repo.create({
id: randomUUID(),
fromUserId: userId,
toUserId: leaderId,
organizationId,
type: LeadershipRequestType.UNLINK_NOTIFICATION,
status: LeadershipRequestStatus.ACCEPTED,
resolvedAt: new Date(),
});
} else if (role === 'remove_follower' && followerIdToRemove) {
const followerLeaderId = await this.repo.getUserLeaderId(followerIdToRemove);
if (followerLeaderId !== userId) throw new LeadershipNotFoundException(this.i18n);
await this.repo.updateUserLeader(followerIdToRemove, null);
const remaining = await this.repo.countFollowers(userId);
if (remaining === 0) {
await this.repo.updateIsLeader(userId, false);
}
await this.repo.create({
id: randomUUID(),
fromUserId: userId,
toUserId: followerIdToRemove,
organizationId,
type: LeadershipRequestType.UNLINK_NOTIFICATION,
status: LeadershipRequestStatus.ACCEPTED,
resolvedAt: new Date(),
});
}
}
}
- [ ] Step 5: Run test to verify it passes
- [ ] Step 6: Commit
git add src/modules/leadership/application/commands/unlink/
git commit -m "feat(leadership): add unlink command with notification to ex-leader"
Task 11: Transfer leadership command
Files:
- Create: src/modules/leadership/application/commands/transfer/transfer_leadership.command.ts
- Create: src/modules/leadership/application/commands/transfer/transfer_leadership.handler.ts
- [ ] Step 1: Create command
Create src/modules/leadership/application/commands/transfer/transfer_leadership.command.ts:
export class TransferLeadershipCommand {
constructor(
public readonly userId: string,
public readonly organizationId: string,
public readonly newLeaderId: string,
) {}
}
- [ ] Step 2: Create handler
Create src/modules/leadership/application/commands/transfer/transfer_leadership.handler.ts:
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { randomUUID } from 'node:crypto';
import { TransferLeadershipCommand } from './transfer_leadership.command';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestType } from '@Leadership/domain/enums/leadership_request_type.enum';
import { LeadershipRequestStatus } from '@Leadership/domain/enums/leadership_request_status.enum';
import { LeadershipSelfRequestException } from '@Leadership/infrastructure/exceptions/leadership_self_request.exception';
import { LeadershipCrossOrgException } from '@Leadership/infrastructure/exceptions/leadership_cross_org.exception';
import { LeadershipDuplicateRequestException } from '@Leadership/infrastructure/exceptions/leadership_duplicate_request.exception';
import { LeadershipNotFoundException } from '@Leadership/infrastructure/exceptions/leadership_not_found.exception';
@CommandHandler(TransferLeadershipCommand)
export class TransferLeadershipHandler
implements ICommandHandler<TransferLeadershipCommand, void>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
private readonly i18n: I18nService,
) {}
async execute(command: TransferLeadershipCommand): Promise<void> {
const { userId, organizationId, newLeaderId } = command;
if (userId === newLeaderId) throw new LeadershipSelfRequestException(this.i18n);
const currentLeaderId = await this.repo.getUserLeaderId(userId);
if (!currentLeaderId) throw new LeadershipNotFoundException(this.i18n);
const targetOrgId = await this.repo.getUserOrgId(newLeaderId);
if (targetOrgId !== organizationId) throw new LeadershipCrossOrgException(this.i18n);
const pending = await this.repo.findPendingBetween(userId, newLeaderId);
if (pending) throw new LeadershipDuplicateRequestException(this.i18n);
await this.repo.create({
id: randomUUID(),
fromUserId: userId,
toUserId: newLeaderId,
organizationId,
type: LeadershipRequestType.TRANSFER_LEADER,
status: LeadershipRequestStatus.PENDING,
});
}
}
- [ ] Step 3: Commit
git add src/modules/leadership/application/commands/transfer/
git commit -m "feat(leadership): add transfer leadership command"
Task 12: Queries — List requests, Search users, Organigrama
Files: - Create all query files listed in the file map
- [ ] Step 1: Create list requests query
Create src/modules/leadership/application/queries/list_requests/list_leadership_requests.query.ts:
Create src/modules/leadership/application/queries/list_requests/list_leadership_requests.handler.ts:
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { ListLeadershipRequestsQuery } from './list_leadership_requests.query';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipRequestResponseDTO } from '@Leadership/application/dtos/leadership_request.dto';
import { LeadershipRequest } from '@Leadership/domain/entities/leadership_request.entity';
@QueryHandler(ListLeadershipRequestsQuery)
export class ListLeadershipRequestsHandler
implements IQueryHandler<ListLeadershipRequestsQuery, LeadershipRequestResponseDTO[]>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
) {}
async execute(query: ListLeadershipRequestsQuery): Promise<LeadershipRequestResponseDTO[]> {
const requests = await this.repo.listForUser(query.userId);
return requests.map((r) => this.toDTO(r, query.userId));
}
private toDTO(request: LeadershipRequest, userId: string): LeadershipRequestResponseDTO {
return {
id: request.id,
fromUserId: request.fromUserId,
toUserId: request.toUserId,
type: request.type,
status: request.status,
createdAt: request.createdAt.toISOString(),
resolvedAt: request.resolvedAt?.toISOString() ?? null,
direction: request.fromUserId === userId ? 'outgoing' : 'incoming',
};
}
}
- [ ] Step 2: Create search users query
Create src/modules/leadership/application/queries/search_users/search_leadership_users.query.ts:
export class SearchLeadershipUsersQuery {
constructor(
public readonly organizationId: string,
public readonly query: string,
) {}
}
Create src/modules/leadership/application/queries/search_users/search_leadership_users.handler.ts:
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { SearchLeadershipUsersQuery } from './search_leadership_users.query';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { LeadershipUserResultDTO } from '@Leadership/application/dtos/leadership_user_search.dto';
@QueryHandler(SearchLeadershipUsersQuery)
export class SearchLeadershipUsersHandler
implements IQueryHandler<SearchLeadershipUsersQuery, LeadershipUserResultDTO[]>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
) {}
async execute(query: SearchLeadershipUsersQuery): Promise<LeadershipUserResultDTO[]> {
const users = await this.repo.searchUsersInOrg(query.organizationId, query.query);
return users.map((u) => ({
id: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
}));
}
}
- [ ] Step 3: Create organigrama query
Create src/modules/leadership/application/queries/get_organigrama/get_organigrama.query.ts:
Create src/modules/leadership/application/queries/get_organigrama/get_organigrama.handler.ts:
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GetOrganigramaQuery } from './get_organigrama.query';
import {
LEADERSHIP_REPOSITORY,
type ILeadershipRepository,
type OrgTreeNode,
} from '@Leadership/infrastructure/db/leadership_request.repository';
import { OrganigramaDTO, OrganigramaNodeDTO } from '@Leadership/application/dtos/organigrama.dto';
@QueryHandler(GetOrganigramaQuery)
export class GetOrganigramaHandler
implements IQueryHandler<GetOrganigramaQuery, OrganigramaDTO>
{
constructor(
@Inject(LEADERSHIP_REPOSITORY)
private readonly repo: ILeadershipRepository,
) {}
async execute(query: GetOrganigramaQuery): Promise<OrganigramaDTO> {
const nodes = await this.repo.getOrgTree(query.organizationId);
return this.buildTree(nodes);
}
private buildTree(nodes: OrgTreeNode[]): OrganigramaDTO {
const nodeMap = new Map<string, OrganigramaNodeDTO>(
nodes.map((n) => [n.id, { id: n.id, name: n.name, subordinates: [] }]),
);
const roots: OrganigramaNodeDTO[] = [];
const unassigned: OrganigramaNodeDTO[] = [];
for (const node of nodes) {
const dto = nodeMap.get(node.id)!;
if (node.leaderId && nodeMap.has(node.leaderId)) {
nodeMap.get(node.leaderId)!.subordinates.push(dto);
} else if (node.leaderId === null) {
const hasFollowers = nodes.some((n) => n.leaderId === node.id);
if (hasFollowers) {
roots.push(dto);
} else {
unassigned.push(dto);
}
} else {
unassigned.push(dto);
}
}
return { roots, unassigned };
}
}
- [ ] Step 4: Commit
git add src/modules/leadership/application/queries/
git commit -m "feat(leadership): add list requests, search users and organigrama queries"
Task 13: HTTP Controller
Files:
- Create: src/modules/leadership/infrastructure/http/leadership_http.controller.ts
- [ ] Step 1: Create controller
Create src/modules/leadership/infrastructure/http/leadership_http.controller.ts:
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
CurrentUser,
HttpApiResponseInterceptor,
HttpExceptionFilter,
LoggingInterceptor,
PermissionGuard,
RequiresPermission,
ValidationInterceptor,
} from '@shared';
import type { ICurrentUserContext } from '@shared';
import { PermissionResource } from '@Role/domain/enums/permission_resource.enum';
import { PermissionAction } from '@Role/domain/enums/permission_action.enum';
import { SendLeadershipRequestCommand } from '@Leadership/application/commands/send_request/send_leadership_request.command';
import { ResolveLeadershipRequestCommand } from '@Leadership/application/commands/resolve_request/resolve_leadership_request.command';
import { CancelLeadershipRequestCommand } from '@Leadership/application/commands/cancel_request/cancel_leadership_request.command';
import { TransferLeadershipCommand } from '@Leadership/application/commands/transfer/transfer_leadership.command';
import { UnlinkLeadershipCommand } from '@Leadership/application/commands/unlink/unlink_leadership.command';
import { ListLeadershipRequestsQuery } from '@Leadership/application/queries/list_requests/list_leadership_requests.query';
import { SearchLeadershipUsersQuery } from '@Leadership/application/queries/search_users/search_leadership_users.query';
import { GetOrganigramaQuery } from '@Leadership/application/queries/get_organigrama/get_organigrama.query';
import {
SendLeadershipRequestDTO,
ResolveLeadershipRequestDTO,
TransferLeadershipDTO,
LeadershipRequestResponseDTO,
} from '@Leadership/application/dtos/leadership_request.dto';
import { SearchLeadershipUsersDTO, LeadershipUserResultDTO } from '@Leadership/application/dtos/leadership_user_search.dto';
import { OrganigramaDTO } from '@Leadership/application/dtos/organigrama.dto';
@ApiTags('leadership')
@ApiBearerAuth()
@Controller()
@UseFilters(HttpExceptionFilter)
@UseInterceptors(HttpApiResponseInterceptor, ValidationInterceptor, LoggingInterceptor)
export class LeadershipHttpController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Get('leadership/search')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Search users in organization by name or email' })
@ApiResponse({ status: 200, type: [LeadershipUserResultDTO] })
async searchUsers(
@CurrentUser() ctx: ICurrentUserContext,
@Query() dto: SearchLeadershipUsersDTO,
): Promise<LeadershipUserResultDTO[]> {
return this.queryBus.execute(
new SearchLeadershipUsersQuery(ctx.organizationId, dto.q),
);
}
@Get('leadership/requests')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'List incoming and outgoing leadership requests' })
@ApiResponse({ status: 200, type: [LeadershipRequestResponseDTO] })
async listRequests(
@CurrentUser() ctx: ICurrentUserContext,
): Promise<LeadershipRequestResponseDTO[]> {
return this.queryBus.execute(new ListLeadershipRequestsQuery(ctx.userId));
}
@Post('leadership/requests')
@HttpCode(HttpStatus.CREATED)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Send a leadership request (REQUEST_LEADER or INVITE_FOLLOWER)' })
@ApiResponse({ status: 201 })
async sendRequest(
@CurrentUser() ctx: ICurrentUserContext,
@Body() dto: SendLeadershipRequestDTO,
): Promise<void> {
await this.commandBus.execute(
new SendLeadershipRequestCommand(ctx.userId, ctx.organizationId, dto.targetUserId, dto.type),
);
}
@Patch('leadership/requests/:id')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Accept or reject an incoming request' })
@ApiResponse({ status: 200 })
async resolveRequest(
@CurrentUser() ctx: ICurrentUserContext,
@Param('id') id: string,
@Body() dto: ResolveLeadershipRequestDTO,
): Promise<void> {
await this.commandBus.execute(
new ResolveLeadershipRequestCommand(id, ctx.userId, dto.action),
);
}
@Delete('leadership/requests/:id')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Cancel a pending outgoing request' })
@ApiResponse({ status: 200 })
async cancelRequest(
@CurrentUser() ctx: ICurrentUserContext,
@Param('id') id: string,
): Promise<void> {
await this.commandBus.execute(
new CancelLeadershipRequestCommand(id, ctx.userId),
);
}
@Post('leadership/transfer')
@HttpCode(HttpStatus.CREATED)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Request transfer to a new leader' })
@ApiResponse({ status: 201 })
async transfer(
@CurrentUser() ctx: ICurrentUserContext,
@Body() dto: TransferLeadershipDTO,
): Promise<void> {
await this.commandBus.execute(
new TransferLeadershipCommand(ctx.userId, ctx.organizationId, dto.newLeaderId),
);
}
@Delete('leadership/relationships/leader')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Remove my current leader' })
@ApiResponse({ status: 200 })
async removeMyLeader(@CurrentUser() ctx: ICurrentUserContext): Promise<void> {
await this.commandBus.execute(
new UnlinkLeadershipCommand(ctx.userId, ctx.organizationId, 'remove_my_leader'),
);
}
@Delete('leadership/relationships/follower/:followerId')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.WRITE)
@ApiOperation({ summary: 'Remove a follower from my team' })
@ApiResponse({ status: 200 })
async removeFollower(
@CurrentUser() ctx: ICurrentUserContext,
@Param('followerId') followerId: string,
): Promise<void> {
await this.commandBus.execute(
new UnlinkLeadershipCommand(ctx.userId, ctx.organizationId, 'remove_follower', followerId),
);
}
@Get('leadership/organigrama')
@HttpCode(HttpStatus.OK)
@UseGuards(PermissionGuard)
@RequiresPermission(PermissionResource.LEADERSHIP, PermissionAction.READ_ORGANIGRAMA)
@ApiOperation({ summary: 'Get full organization chart (requires READ_ORGANIGRAMA permission)' })
@ApiResponse({ status: 200, type: OrganigramaDTO })
async getOrganigrama(@CurrentUser() ctx: ICurrentUserContext): Promise<OrganigramaDTO> {
return this.queryBus.execute(new GetOrganigramaQuery(ctx.organizationId));
}
}
- [ ] Step 2: Commit
git add src/modules/leadership/infrastructure/http/
git commit -m "feat(leadership): add HTTP controller with all endpoints"
Task 14: Module registration
Files:
- Create: src/modules/leadership/leadership.module.ts
- Modify: src/modules/app.module.ts
- [ ] Step 1: Create module
Create src/modules/leadership/leadership.module.ts:
import { Module } from '@nestjs/common';
import { LeadershipHttpController } from './infrastructure/http/leadership_http.controller';
import { LEADERSHIP_REPOSITORY } from './infrastructure/db/leadership_request.repository';
import { LeadershipPrismaRepository } from './infrastructure/db/leadership_request.prisma';
import { SendLeadershipRequestHandler } from './application/commands/send_request/send_leadership_request.handler';
import { ResolveLeadershipRequestHandler } from './application/commands/resolve_request/resolve_leadership_request.handler';
import { CancelLeadershipRequestHandler } from './application/commands/cancel_request/cancel_leadership_request.handler';
import { TransferLeadershipHandler } from './application/commands/transfer/transfer_leadership.handler';
import { UnlinkLeadershipHandler } from './application/commands/unlink/unlink_leadership.handler';
import { ListLeadershipRequestsHandler } from './application/queries/list_requests/list_leadership_requests.handler';
import { SearchLeadershipUsersHandler } from './application/queries/search_users/search_leadership_users.handler';
import { GetOrganigramaHandler } from './application/queries/get_organigrama/get_organigrama.handler';
@Module({
providers: [
{ provide: LEADERSHIP_REPOSITORY, useClass: LeadershipPrismaRepository },
SendLeadershipRequestHandler,
ResolveLeadershipRequestHandler,
CancelLeadershipRequestHandler,
TransferLeadershipHandler,
UnlinkLeadershipHandler,
ListLeadershipRequestsHandler,
SearchLeadershipUsersHandler,
GetOrganigramaHandler,
],
controllers: [LeadershipHttpController],
})
export class LeadershipModule {}
- [ ] Step 2: Register in AppModule
In src/modules/app.module.ts, add the import and module:
import { LeadershipModule } from './leadership/leadership.module';
// In @Module imports array, add after EmapModule:
LeadershipModule,
- [ ] Step 3: Add TypeScript path alias
In tsconfig.json, add inside compilerOptions.paths:
- [ ] Step 4: Build to verify no TypeScript errors
- [ ] Step 5: Run all tests
- [ ] Step 6: Commit
git add src/modules/leadership/leadership.module.ts src/modules/app.module.ts tsconfig.json
git commit -m "feat(leadership): register module, wire all handlers and add path alias"
Task 15: Run migrations
- [ ] Step 1: Apply migrations to development database
- [ ] Step 2: Verify Prisma client has new models
leadershipRequests model and new fields on users.
- [ ] Step 3: Final build check
- [ ] Step 4: Final commit
Self-review
Spec coverage check:
- ✅ CU-1 Request a leader → SendLeadershipRequestCommand with REQUEST_LEADER type
- ✅ CU-2 Invite a follower → SendLeadershipRequestCommand with INVITE_FOLLOWER type
- ✅ CU-3 Manage received requests → ResolveLeadershipRequestHandler (accept/reject)
- ✅ CU-4 Cancel sent request → CancelLeadershipRequestHandler
- ✅ CU-5 Transfer leadership → TransferLeadershipHandler + TRANSFER_LEADER resolved in ResolveLeadershipRequestHandler
- ✅ CU-6 Unlink relationship → UnlinkLeadershipHandler (remove_my_leader + remove_follower)
- ✅ CU-7 View organigrama → GetOrganigramaHandler with READ_ORGANIGRAMA permission
- ✅ R-1.1 Acceptance required → both sides via resolve handler
- ✅ R-1.2 One leader max → LeadershipAlreadyHasLeaderException in send handler
- ✅ R-1.3 No duplicates → findPendingBetween check
- ✅ R-1.4 No request if relation exists → covered by already_has_leader check
- ✅ R-1.5 Same org only → getUserOrgId cross-org check
- ✅ R-1.6 No self-request → LeadershipSelfRequestException
- ✅ R-1.7 Transfer replaces previous → in applyAcceptance for TRANSFER_LEADER
- ✅ R-1.8 Unlink notifies other party → UNLINK_NOTIFICATION record created
- ✅ Permissions → LEADERSHIP + WRITE (all) + LEADERSHIP + READ_ORGANIGRAMA (specific)
- ✅ Search users by name/email → searchUsersInOrg in repository