Saltar a contenido

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

npx prisma generate
Expected: Prisma Client generated successfully.

  • [ ] 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

npx prisma generate
Expected: Prisma Client generated successfully with 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

npx jest send_leadership_request.handler.spec.ts --no-coverage
Expected: FAIL — handler file not found.

  • [ ] 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

npx jest send_leadership_request.handler.spec.ts --no-coverage
Expected: PASS — all 5 tests pass.

  • [ ] 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

npx jest resolve_leadership_request.handler.spec.ts --no-coverage
Expected: FAIL — handler file not found.

  • [ ] 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

npx jest resolve_leadership_request.handler.spec.ts --no-coverage
Expected: PASS — all 5 tests pass.

  • [ ] 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

npx jest cancel_leadership_request.handler.spec.ts --no-coverage
Expected: FAIL.

  • [ ] 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

npx jest cancel_leadership_request.handler.spec.ts --no-coverage
Expected: PASS — all 4 tests pass.

  • [ ] Step 6: Commit
git add src/modules/leadership/application/commands/cancel_request/
git commit -m "feat(leadership): add cancel request 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

npx jest unlink_leadership.handler.spec.ts --no-coverage
Expected: FAIL.

  • [ ] 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

npx jest unlink_leadership.handler.spec.ts --no-coverage
Expected: PASS — all 3 tests pass.

  • [ ] 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:

export class ListLeadershipRequestsQuery {
  constructor(public readonly userId: string) {}
}

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:

export class GetOrganigramaQuery {
  constructor(public readonly organizationId: string) {}
}

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:

"@Leadership/*": ["src/modules/leadership/*"]

  • [ ] Step 4: Build to verify no TypeScript errors

npx tsc --noEmit
Expected: no errors.

  • [ ] Step 5: Run all tests

npx jest --testPathPattern="leadership" --no-coverage
Expected: all leadership tests pass.

  • [ ] 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

npx prisma migrate deploy
Expected: 3 new migrations applied successfully.

  • [ ] Step 2: Verify Prisma client has new models

npx prisma generate
Expected: Client generated with leadershipRequests model and new fields on users.

  • [ ] Step 3: Final build check

npm run build
Expected: BUILD SUCCESSFUL, no errors.

  • [ ] Step 4: Final commit
git add prisma/
git commit -m "feat(leadership): apply all leadership module migrations"

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