src/app/features/de-duplication/bulk-merge-records/merge-account-section/merge-account-section.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  input,
  OnInit,
  signal,
} from "@angular/core";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatError, MatFormFieldModule } from "@angular/material/form-field";
import { MatRadioModule } from "@angular/material/radio";
import { MatSelectModule } from "@angular/material/select";
import { YesNoCancelButtons } from "app/core/common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component";
import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service";
import { Entity, EntityConstructor } from "app/core/entity/model/entity";
import { UserAdminService } from "app/core/user/user-admin-service/user-admin.service";
import {
  Role,
  UserAccount,
} from "app/core/user/user-admin-service/user-account";
import { catchError, lastValueFrom, of } from "rxjs";

export interface AccountMergeDecision {
  accountUpdate: {
    accountId: string;
    update: Partial<UserAccount>;
  } | null;
  deleteSecondaryAccount: boolean;
}

@Component({
  selector: "app-merge-account-section",
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { style: "display: contents" },
  imports: [
    ReactiveFormsModule,
    MatError,
    MatRadioModule,
    MatExpansionModule,
    MatCheckboxModule,
    MatFormFieldModule,
    MatSelectModule,
  ],
  templateUrl: "./merge-account-section.component.html",
  styleUrl: "./merge-account-section.component.scss",
})
export class MergeAccountSectionComponent implements OnInit {
  private readonly userAdminService = inject(UserAdminService);
  private readonly confirmationDialog = inject(ConfirmationDialogService);

  entitiesToMerge = input.required<Entity[]>();
  entityConstructor = input.required<EntityConstructor>();

  readonly entityAccounts = signal<(UserAccount | null)[]>([null, null]);
  readonly accountLoadError = signal<boolean>(false);
  /** Index of the entity whose ID will be retained after merge. */
  readonly primaryIndex = signal<number>(0);
  readonly selectedAccountIndex = signal<number | null>(null);
  readonly availableRoles = signal<Role[]>([]);
  readonly selectedRolesControl = signal<FormControl<Role[]>>(
    new FormControl<Role[]>([], { nonNullable: true }),
  );
  readonly deleteSecondaryAccount = signal<boolean>(true);

  readonly hasAnyUserAccount = computed(() =>
    this.entityAccounts().some((a) => a != null),
  );
  readonly selectedAccountIndexValue = computed(() => {
    const selectedIndex = this.selectedAccountIndex();
    return selectedIndex == null ? null : String(selectedIndex);
  });

  ngOnInit(): Promise<void> {
    return this.initializeAccountData();
  }

  private async initializeAccountData(): Promise<void> {
    await this.loadAccounts();
    if (!this.hasAnyUserAccount()) return;

    await this.loadAvailableRoles();
    this.setPrimaryIndex(this.primaryIndex());
  }

  private async loadAccounts(): Promise<void> {
    if (!this.entityConstructor().enableUserAccounts) return;

    const results = await Promise.all(
      this.entitiesToMerge().map((e) => this.fetchUserAccount(e)),
    );
    this.entityAccounts.set(results.map((r) => r.account));
    this.accountLoadError.set(results.some((r) => r.error));

    if (!results[0].account && results[1]?.account) {
      this.primaryIndex.set(1);
    } else {
      this.primaryIndex.set(0);
    }
  }

  private async fetchUserAccount(
    entity: Entity,
  ): Promise<{ account: UserAccount | null; error: boolean }> {
    try {
      const account = await lastValueFrom(
        this.userAdminService.getUser(entity.getId()).pipe(
          catchError((err) => {
            if (err?.status === 404) return of(null);
            throw err;
          }),
        ),
      );
      return { account, error: false };
    } catch {
      return { account: null, error: true };
    }
  }

  private async loadAvailableRoles(): Promise<void> {
    try {
      this.availableRoles.set(
        await lastValueFrom(this.userAdminService.getAllRoles()),
      );
    } catch {
      this.availableRoles.set(
        this.uniqueRoles(
          this.entityAccounts().flatMap((account) => account?.roles ?? []),
        ),
      );
    }
  }

  hasSelectableAccount(index: number): boolean {
    return this.entityAccounts()[index] != null;
  }

  hasAccountEmail(index: number): boolean {
    return !!this.entityAccounts()[index]?.email;
  }

  selectAccountToKeep(index: number | string): void {
    const parsedIndex = Number(index);
    if (
      !Number.isInteger(parsedIndex) ||
      !this.hasSelectableAccount(parsedIndex)
    ) {
      return;
    }
    this.setPrimaryIndex(parsedIndex);
  }

  private setPrimaryIndex(index: number): void {
    this.primaryIndex.set(index);
    this.selectedAccountIndex.set(index);
    this.resetSelectedRolesForIndex(index);
  }

  private resetSelectedRolesForIndex(index: number): void {
    const roles = this.getAccountRoles(index);
    const control = this.selectedRolesControl();
    control.setValue(roles);
    control.markAsPristine();
  }

  formatRoles(account: UserAccount | null | undefined): string {
    if (!account?.roles?.length) return "-";
    return account.roles.map((r) => r.name).join(", ");
  }

  formatAccountStatus(account: UserAccount | null | undefined): string {
    if (!account) return "-";
    return account.enabled ? $localize`Enabled` : $localize`Disabled`;
  }

  formatSelectedAccountStatus(): string {
    const selectedIndex = this.selectedAccountIndex();
    if (selectedIndex == null) return "-";
    return this.formatAccountStatus(this.entityAccounts()[selectedIndex]);
  }

  isAccountRolesSelected(index: number): boolean {
    const selectedRoles = this.selectedRolesControl().value;
    const selectedRoleIds = new Set(selectedRoles.map((r) => r.id));
    const accountRoles = this.getAccountRoles(index);

    if (!accountRoles.length) return false;
    return accountRoles.every((role) => selectedRoleIds.has(role.id));
  }

  toggleAccountRoles(index: number, checked: boolean): void {
    const selectedRoles = this.selectedRolesControl().value;
    const accountRoles = this.getAccountRoles(index);
    if (!accountRoles.length) return;

    const selectedMap = new Map(selectedRoles.map((r) => [r.id, r]));
    if (checked) {
      accountRoles.forEach((role) => selectedMap.set(role.id, role));
    } else {
      accountRoles.forEach((role) => selectedMap.delete(role.id));
    }

    this.selectedRolesControl().setValue(Array.from(selectedMap.values()));
    this.selectedRolesControl().markAsDirty();
  }

  getAccountRoles(index: number): Role[] {
    return this.uniqueRoles(
      (this.entityAccounts()[index]?.roles ?? []).map(
        (role) =>
          this.availableRoles().find(
            (availableRole) => availableRole.id === role.id,
          ) ?? role,
      ),
    );
  }

  private uniqueRoles(roles: Role[]): Role[] {
    const roleMap = new Map(roles.map((role) => [role.id, role]));
    return Array.from(roleMap.values());
  }

  private buildAccountUpdate(): {
    accountId: string;
    update: Partial<UserAccount>;
  } | null {
    const selectedIndex = this.selectedAccountIndex();
    if (selectedIndex == null) return null;

    const selectedAccount = this.entityAccounts()[selectedIndex];
    if (!selectedAccount?.id) return null;

    const control = this.selectedRolesControl();
    if (!control.dirty) return null;

    const selectedRoles = control.value ?? [];
    const existingRoles = selectedAccount.roles ?? [];

    const selectedIds = new Set(selectedRoles.map((r) => r.id));
    const existingIds = new Set(existingRoles.map((r) => r.id));
    const rolesChanged =
      selectedIds.size !== existingIds.size ||
      [...selectedIds].some((id) => !existingIds.has(id));

    if (!rolesChanged) return null;

    return {
      accountId: selectedAccount.id,
      update: { roles: selectedRoles },
    };
  }

  async validateAndGetDecision(): Promise<false | AccountMergeDecision> {
    this.deleteSecondaryAccount.set(true);

    if (
      this.accountLoadError() &&
      this.entityConstructor().enableUserAccounts
    ) {
      const confirmed = await this.confirmationDialog.getConfirmation(
        $localize`:merge account load error title:Warning! User account status unknown`,
        $localize`:merge account load error:User account information could not be loaded (you may be offline or lack account_manager permissions). Proceeding may leave an orphaned user account.`,
      );
      if (!confirmed) return false;
    }

    const accountsFound = this.entityAccounts().filter((a) => a != null);
    if (accountsFound.length === 1) {
      const confirmed = await this.confirmationDialog.getConfirmation(
        $localize`:merge account warning title:Warning! User account(s) found`,
        $localize`:merge account warning one account:One of the records has a linked user account. This account will remain linked as a login for the merged record.`,
      );
      if (!confirmed) return false;
    }

    if (accountsFound.length === 2) {
      const selectedIndex = this.selectedAccountIndex() ?? this.primaryIndex();
      const secondaryIndex = selectedIndex === 0 ? 1 : 0;
      const selectedEmail = this.entityAccounts()[selectedIndex]?.email ?? "-";
      const secondaryEmail =
        this.entityAccounts()[secondaryIndex]?.email ?? "-";

      const result = await this.confirmationDialog.getConfirmation(
        $localize`:merge account warning title:Warning! User account(s) found`,
        $localize`:merge account warning both accounts:Both records have a linked user account. You have selected the account for ${selectedEmail}:selectedEmail: to remain linked to the merged record.\nDo you want to delete the second account ${secondaryEmail}:secondaryEmail: (which will not have a linked record after this merge)?`,
        YesNoCancelButtons,
      );

      if (result === undefined) {
        return false;
      }
      this.deleteSecondaryAccount.set(result === true);
    }

    return {
      accountUpdate: this.buildAccountUpdate(),
      deleteSecondaryAccount: this.deleteSecondaryAccount(),
    };
  }
}

results matching ""

    No results matching ""