All files / libs/tenants/teams/core/src/lib/use-cases link-existing-teams.use-case.ts

97.1% Statements 168/173
89.47% Branches 17/19
100% Functions 7/7
97.1% Lines 168/173

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 1741x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 17x 17x 17x 17x 1x 1x 1x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 8x 8x 8x 8x 8x 8x 8x 8x 10x 10x 10x 10x 10x 8x 8x 8x 8x 8x 8x 8x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x           4x 4x 8x 8x 2x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 18x 18x 18x 18x 18x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 1x 1x 6x 6x 6x 6x 6x 1x 1x 16x 16x 16x 16x 1x  
import { ForbiddenException, Logger, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { pick } from 'lodash-es';
import { Repository } from 'typeorm';
import { runOnTransactionCommit, Transactional } from 'typeorm-transactional';
 
import { Team, type Company } from '@amalia/core/models';
import { assert, promiseAllSettledAutoThrow, toError } from '@amalia/ext/typescript';
import { canModifyTeams, defineAbilityFor } from '@amalia/kernel/auth/shared';
import { type AuthenticatedContext } from '@amalia/kernel/auth/types';
import { RecordObject, RecordType } from '@amalia/tenants/monitoring/audit/types';
import { getTeamAncestors, makeTeamsTree } from '@amalia/tenants/teams/shared/tree';
import { type LinkExistingTeamsRequest } from '@amalia/tenants/teams/types';
 
import { TeamUpdatedEvent } from '../events/team-updated-event';
 
type TeamMin = Pick<Team, 'archived' | 'id' | 'name' | 'parentTeamId'> & { parentTeam?: Pick<Team, 'id' | 'name'> };
 
export class LinkExistingTeamsUseCase {
  private readonly logger = new Logger(LinkExistingTeamsUseCase.name);
 
  public constructor(
    private readonly eventBus: EventBus,
    @InjectRepository(Team)
    private readonly teamsRepository: Repository<Team>,
  ) {}
 
  @Transactional()
  public async execute({
    company,
    authenticatedContext,
    teamId,
    linkExistingTeamRequest,
  }: {
    company: Company;
    authenticatedContext: AuthenticatedContext;
    teamId: Team['id'];
    linkExistingTeamRequest: LinkExistingTeamsRequest;
  }) {
    this.assertCanExecute(authenticatedContext);
 
    // Get all teams in the company. Only select needed fields.
    const allTeams: TeamMin[] = await this.teamsRepository.find({
      where: { company: { id: company.id } },
      relations: ['parentTeam'],
      select: {
        id: true,
        parentTeamId: true,
        archived: true,
        name: true,
        parentTeam: {
          id: true,
          name: true,
        },
      },
    });
 
    // Get the target parent team and make sure that it exists and is not archived.
    const targetParentTeam = this.getAndValidateTeam(allTeams, teamId);
    // Get the list of ancestors of the target parent team.
    const targetParentTeamAncestorsIds = this.getTargetParentTeamAncestorIds(allTeams, teamId);
 
    // Get the children teams and make sure that they exist, are not archived and are not ancestors of their target parent team.
    const childrenTeams = linkExistingTeamRequest.childrenTeamIds.map((childTeamId) =>
      this.getAndValidateChildTeam({
        targetParentTeam,
        targetParentTeamAncestorsIds,
        allTeams,
        childTeamId,
      }),
    );
 
    await promiseAllSettledAutoThrow(
      // Update each child team to set the target parent team as their parent.
      // Publish a log for each child team when the transaction is committed.
      childrenTeams.map(async (childTeam) => {
        // Note: we have to use save and not update because typeorm is a useless pile of shit and it doesn't update the closure table with update.
        await this.teamsRepository.save({
          id: childTeam.id,
          parentTeamId: targetParentTeam.id,
          parentTeam: targetParentTeam,
        });
 
        runOnTransactionCommit(() =>
          this.publishAuditLog({
            authenticatedContext,
            originalTeam: childTeam,
            updatedTeam: {
              ...childTeam,
              parentTeamId: targetParentTeam.id,
              parentTeam: pick(targetParentTeam, ['id', 'name']),
            },
          }).catch((error) => {
            this.logger.error({
              message: 'Failed to publish TeamUpdatedEvent',
              error: toError(error),
              team: childTeam,
            });
          }),
        );
      }),
    );
  }
 
  private async publishAuditLog({
    authenticatedContext,
    originalTeam,
    updatedTeam,
  }: {
    authenticatedContext: AuthenticatedContext;
    originalTeam: TeamMin;
    updatedTeam: TeamMin;
  }) {
    assert(updatedTeam.parentTeam, 'Parent team should be defined here');
 
    await this.eventBus.publish(
      new TeamUpdatedEvent({
        authenticatedContext,
        object: RecordObject.TEAM,
        type: RecordType.EDIT,
        values: {
          target: updatedTeam,
          oldValues: { parentTeam: originalTeam.parentTeam?.name ?? null },
          newValues: { parentTeam: updatedTeam.parentTeam.name },
        },
      }),
    );
  }
 
  private getAndValidateTeam(allTeams: TeamMin[], teamId: Team['id']) {
    const team = allTeams.find((team) => team.id === teamId);
    assert(team, new NotFoundException(`Team ${teamId} not found`));
    assert(!team.archived, new UnprocessableEntityException(`Team ${team.name} is archived`));
    return team;
  }
 
  private getAndValidateChildTeam({
    targetParentTeam,
    targetParentTeamAncestorsIds,
    allTeams,
    childTeamId,
  }: {
    targetParentTeam: TeamMin;
    targetParentTeamAncestorsIds: Team['id'][];
    allTeams: TeamMin[];
    childTeamId: Team['id'];
  }) {
    const childTeam = this.getAndValidateTeam(allTeams, childTeamId);
 
    assert(childTeam.id !== targetParentTeam.id, new UnprocessableEntityException('Team cannot be its own ancestor'));
 
    assert(
      !targetParentTeamAncestorsIds.includes(childTeam.id),
      new UnprocessableEntityException(`Team ${childTeam.name} is an ancestor of team ${targetParentTeam.name}`),
    );
 
    return childTeam;
  }
 
  private getTargetParentTeamAncestorIds(allTeams: TeamMin[], targetParentTeamId: Team['id']) {
    const teamsTree = makeTeamsTree(allTeams);
    const targetParentNode = teamsTree.find((node) => node.team.id === targetParentTeamId);
    assert(targetParentNode, 'Team not found in the tree (this should be impossible)');
    return getTeamAncestors(targetParentNode).map((ancestor) => ancestor.id);
  }
 
  private assertCanExecute(authenticatedContext: AuthenticatedContext) {
    const ability = defineAbilityFor(authenticatedContext);
 
    assert(canModifyTeams(ability), new ForbiddenException('You do not have the permission to modify teams'));
  }
}