fix(clients): rebuild CKYC document upload as a popup with full proof details
The CKYC tab's document cards now open a dedicated dialog (with client-side
preview) instead of expanding inline, collecting document type, number, and
issue/expiry dates the same way client creation does. Fixes several bugs
found along the way:
- Uploading a document type that's valid for both identity and address
(Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint"
and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are
both resolved.
- Edit Client's Identity-proof and Address-proof steps no longer show each
other's document when both are dual-purpose OVD types, and no longer show
stale/mismatched front-back scans after a document type change.
- Address-proof's own document preview never showed a document at all when
no m_client_kyc record existed yet.
Document number and issue/expiry dates are persisted on the identifier
(documentKey + a shared "Issue date: ..., Expiry date: ..." description
convention) and read back by both the CKYC tab and Edit Client so the same
details appear everywhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+44
-12
@@ -221,6 +221,22 @@
|
||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Expiry date (optional)</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>event_busy</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="addrExpiryDatePicker"
|
||||
formControlName="expiryDate"
|
||||
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="addrExpiryDatePicker"
|
||||
[disabled]="readOnly || isAddressProofLinkedToKyc"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #addrExpiryDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
|
||||
@@ -238,14 +254,18 @@
|
||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
|
||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
@if (existingAddrFrontSlotPreview) {
|
||||
<img
|
||||
[src]="existingAddrFrontSlotPreview"
|
||||
alt="Saved address proof"
|
||||
class="addr-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
{{ existingAddrFrontSlotName }}
|
||||
</span>
|
||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
@@ -278,14 +298,18 @@
|
||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||
click anywhere to replace. -->
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
@if (existingAddrFrontSlotPreview) {
|
||||
<img
|
||||
[src]="existingAddrFrontSlotPreview"
|
||||
alt="Saved address proof"
|
||||
class="addr-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
{{ existingAddrFrontSlotName }}
|
||||
</span>
|
||||
<div class="addr-dropzone__existing-actions">
|
||||
<button
|
||||
@@ -327,14 +351,18 @@
|
||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
|
||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
@if (existingAddrBackSlotPreview) {
|
||||
<img
|
||||
[src]="existingAddrBackSlotPreview"
|
||||
alt="Saved address proof"
|
||||
class="addr-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
{{ existingAddrBackSlotName }}
|
||||
</span>
|
||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
@@ -367,14 +395,18 @@
|
||||
<!-- Edit mode WITH existing combined doc — same view + replace
|
||||
pattern as the front slot, anchored to the saved file. -->
|
||||
<div class="addr-dropzone__existing">
|
||||
@if (addrMergedPreview) {
|
||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
||||
@if (existingAddrBackSlotPreview) {
|
||||
<img
|
||||
[src]="existingAddrBackSlotPreview"
|
||||
alt="Saved address proof"
|
||||
class="addr-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="addr-dropzone__existing-meta">
|
||||
<span class="addr-dropzone__existing-name">
|
||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
||||
{{ existingAddrBackSlotName }}
|
||||
</span>
|
||||
<div class="addr-dropzone__existing-actions">
|
||||
<button
|
||||
|
||||
+103
-11
@@ -102,7 +102,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
Validators.required
|
||||
],
|
||||
documentKey: [''],
|
||||
issueDate: ['']
|
||||
issueDate: [''],
|
||||
expiryDate: ['']
|
||||
});
|
||||
|
||||
currentForm: UntypedFormGroup = this.fb.group({
|
||||
@@ -222,6 +223,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
size: number;
|
||||
previewUrl: string | null;
|
||||
}[] = [];
|
||||
/** Set only when the saved documents are separately-uploaded "<Proof> — Front"/"— Back"
|
||||
* files (the CKYC tab / client-kyc-step convention) rather than this component's own single
|
||||
* merged-PNG upload — lets the two slots show their own file instead of both showing
|
||||
* whichever one happened to be first. */
|
||||
private existingAddrFrontPreview: string | null = null;
|
||||
private existingAddrBackPreview: string | null = null;
|
||||
|
||||
residenceTypes = [
|
||||
'Owned',
|
||||
@@ -307,11 +314,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.existingAddressProofDocuments = [];
|
||||
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
||||
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
||||
if (this.existingKycDocSides.front?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
|
||||
} else if (this.existingKycDocSides.back?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
|
||||
}
|
||||
// These already carry their own per-side preview from the KYC step's own mapping —
|
||||
// keep the two slots distinct rather than collapsing them into one shared preview.
|
||||
this.existingAddrFrontPreview = this.existingKycDocSides.front?.previewUrl || null;
|
||||
this.existingAddrBackPreview = this.existingKycDocSides.back?.previewUrl || null;
|
||||
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
||||
}
|
||||
} else if (this.isAddressProofLinkedToKyc) {
|
||||
@@ -327,6 +333,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedPreview = null;
|
||||
this.existingAddrFrontPreview = null;
|
||||
this.existingAddrBackPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,6 +364,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedFile = null;
|
||||
this.addrMergedPreview = null;
|
||||
this.existingAddrFrontPreview = null;
|
||||
this.existingAddrBackPreview = null;
|
||||
this.existingAddressProofIdentifierId = null;
|
||||
this.existingAddressProofDocuments = [];
|
||||
this.addressLoaded = false;
|
||||
@@ -488,6 +498,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
proofId: any;
|
||||
documentKey: string;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
files: { slot: string; file: File }[];
|
||||
} {
|
||||
const files: { slot: string; file: File }[] = [];
|
||||
@@ -500,11 +511,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||
const rawExpiryDate = this.permanentForm.get('expiryDate')?.value;
|
||||
return {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||
expiryDate: rawExpiryDate ? this.dates.getDate(rawExpiryDate) : '',
|
||||
files
|
||||
};
|
||||
}
|
||||
@@ -644,7 +657,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.tryLinkKycData();
|
||||
return;
|
||||
}
|
||||
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
|
||||
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for both this slot and
|
||||
// the separate identity-proof one (client-kyc-step) — when a client has two such
|
||||
// dual-purpose identifiers, that step always runs first and picks the OLDEST match (the
|
||||
// one on file first, historically the identity proof), so picking the NEWEST match here
|
||||
// lands this step on the other row without the two steps needing to coordinate.
|
||||
const identifier = [...identifiers]
|
||||
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))
|
||||
.find((i: any) => this.findMatchingProof(i?.documentType?.name));
|
||||
if (!identifier?.id) {
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.tryLinkKycData();
|
||||
@@ -664,14 +684,19 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
proofId: matchedProof.id,
|
||||
documentKey: identifier.documentKey || ''
|
||||
});
|
||||
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||
// shows what was previously entered.
|
||||
// The description carries "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" when set on
|
||||
// create (see create-client.saveAddressProof / CkycTabComponent) — pull both out so the
|
||||
// form's date fields show what was previously entered. Each match stops at the next
|
||||
// comma so adding expiry after issue doesn't get swallowed into the issue-date capture.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
|
||||
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
|
||||
if (issueMatch) {
|
||||
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||
}
|
||||
if (expiryMatch) {
|
||||
this.permanentForm.patchValue({ expiryDate: this.dates.parseDate(expiryMatch[1].trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
|
||||
@@ -685,6 +710,29 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
size: d.size || 0,
|
||||
previewUrl: null as string | null
|
||||
}));
|
||||
|
||||
const sideOf = (d: { name: string; fileName: string }): 'front' | 'back' | null => {
|
||||
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
|
||||
if (text.includes('back')) return 'back';
|
||||
if (text.includes('front')) return 'front';
|
||||
return null;
|
||||
};
|
||||
// Changing the document type (e.g. Aadhaar -> Passport) keeps the old front/back
|
||||
// files attached to this same identifier rather than removing them, so there can be
|
||||
// more than one "— Front"/"— Back" pair on file — sort newest-first before matching
|
||||
// or this would keep showing the very first scan ever uploaded here.
|
||||
const byNewest = [...this.existingAddressProofDocuments].sort((a, b) => Number(b.id) - Number(a.id));
|
||||
const frontDoc = byNewest.find((d) => sideOf(d) === 'front');
|
||||
const backDoc = byNewest.find((d) => sideOf(d) === 'back');
|
||||
|
||||
if (frontDoc || backDoc) {
|
||||
// Separately-uploaded front/back — each side gets its own preview.
|
||||
if (frontDoc) this.downloadAddrSidePreview(frontDoc, 'front');
|
||||
if (backDoc) this.downloadAddrSidePreview(backDoc, 'back');
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy/single-file case: one combined (or unlabelled) document covers both slots.
|
||||
const firstImage = this.existingAddressProofDocuments.find((d) =>
|
||||
(d.type || '').toLowerCase().startsWith('image/')
|
||||
);
|
||||
@@ -715,6 +763,50 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
private downloadAddrSidePreview(doc: { id: string; fileName: string; name: string }, slot: 'front' | 'back'): void {
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, doc.id).subscribe({
|
||||
next: async (blob: any) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const url = 'data:' + blob.type + ';base64,' + btoa(binary);
|
||||
if (slot === 'front') this.existingAddrFrontPreview = url;
|
||||
else this.existingAddrBackPreview = url;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** What the Front slot should show: its own saved file when front/back were uploaded
|
||||
* separately, falling back to the single combined preview for legacy/merged uploads. */
|
||||
get existingAddrFrontSlotPreview(): string | null {
|
||||
return this.existingAddrFrontPreview || this.addrMergedPreview;
|
||||
}
|
||||
|
||||
get existingAddrFrontSlotName(): string {
|
||||
const front = this.existingAddressProofDocuments.find((d) =>
|
||||
`${d.name} ${d.fileName}`.toLowerCase().includes('front')
|
||||
);
|
||||
const doc = front || this.existingAddressProofDocuments[0];
|
||||
return doc?.fileName || doc?.name || '';
|
||||
}
|
||||
|
||||
/** Same as above, for the Back slot. */
|
||||
get existingAddrBackSlotPreview(): string | null {
|
||||
return this.existingAddrBackPreview || this.addrMergedPreview;
|
||||
}
|
||||
|
||||
get existingAddrBackSlotName(): string {
|
||||
const back = this.existingAddressProofDocuments.find((d) =>
|
||||
`${d.name} ${d.fileName}`.toLowerCase().includes('back')
|
||||
);
|
||||
const doc = back || this.existingAddressProofDocuments[0];
|
||||
return doc?.fileName || doc?.name || '';
|
||||
}
|
||||
|
||||
onPinInput(tab: string) {
|
||||
const form = this.getForm(tab);
|
||||
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||
import { forkJoin, catchError, of } from 'rxjs';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MatDialog,
|
||||
@@ -1266,8 +1267,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
// No m_client_kyc row yet (e.g. CERSAI sync hasn't run) — that's fine, but the client
|
||||
// can still have a KYC-type identifier already on file (uploaded via the CKYC tab,
|
||||
// which writes straight to client_identifier and never touches this record). Look it
|
||||
// up the same way the success branch does, just without a document-name hint.
|
||||
this.kycLoaded = true;
|
||||
this.loadExistingPhoto();
|
||||
this.loadExistingSignature();
|
||||
this.loadExistingKycIdentifier(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1283,8 +1290,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
forkJoin({
|
||||
identifiers: this.clientsService.getClientIdentifiers(this.clientId),
|
||||
// Only needed to spot the dual-purpose OVD types (Aadhaar/Passport/Voter ID/Driving
|
||||
// Licence) below — a failure here just means that disambiguation step is skipped.
|
||||
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ addressProofs: [] })))
|
||||
}).subscribe({
|
||||
next: ({ identifiers, proofTypes }: { identifiers: any[]; proofTypes: any }) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const isKycType = (i: any) => {
|
||||
@@ -1297,6 +1309,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
})
|
||||
);
|
||||
};
|
||||
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for BOTH this slot and
|
||||
// the separate address-proof one (client-india-address-step) — a client with, say, a PAN
|
||||
// identity proof and an Aadhaar address proof has two client_identifier rows that both
|
||||
// pass isKycType. Without a name to go on, prefer whichever candidate is NOT also an
|
||||
// address-proof type, so this slot doesn't grab the other step's file.
|
||||
const addressTypeNames = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => cleanStr(p?.name)));
|
||||
const isAddressType = (i: any) => addressTypeNames.has(cleanStr(i?.documentType?.name));
|
||||
// Re-uploading a KYC document creates a new identifier row rather than updating the
|
||||
// old one in place (same pattern as OVD identifiers), so sort newest-first before
|
||||
// picking — otherwise a repeat upload of the same document type would keep showing
|
||||
@@ -1311,7 +1330,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return name === kName || name.includes(kName) || kName.includes(name);
|
||||
})
|
||||
: null;
|
||||
const identifier = match || sorted.find(isKycType);
|
||||
// Last resort, when EVERY kyc-type candidate is also address-eligible (e.g. a client
|
||||
// whose identity proof and address proof are both Aadhaar/Passport/Voter ID/Driving
|
||||
// Licence): this stepper always runs before the address step in both the create and
|
||||
// edit wizards, so the identity proof is the one that was on file first — pick the
|
||||
// OLDEST match here, and the address step (client-india-address-step) picks the NEWEST
|
||||
// among ITS own address-eligible candidates, so the two steps land on different rows
|
||||
// without needing to coordinate at runtime.
|
||||
const identifier =
|
||||
match || sorted.find((i) => isKycType(i) && !isAddressType(i)) || [...sorted].reverse().find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
@@ -1327,6 +1354,19 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
}
|
||||
// Same for issue/expiry date — the CKYC tab (and client-india-address-step, for address
|
||||
// proof) write "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" into the identifier's
|
||||
// description; read it back here only to fill in whatever the KYC record itself doesn't
|
||||
// already have, same as the documentKey backfill above.
|
||||
const desc: string = identifier?.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
|
||||
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
|
||||
if (issueMatch && !this.kycForm.get('kycDocIssueDate')?.value) {
|
||||
this.kycForm.patchValue({ kycDocIssueDate: this.parseApiDate(issueMatch[1].trim()) });
|
||||
}
|
||||
if (expiryMatch && !this.kycForm.get('kycDocExpiryDate')?.value) {
|
||||
this.kycForm.patchValue({ kycDocExpiryDate: this.parseApiDate(expiryMatch[1].trim()) });
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
|
||||
Reference in New Issue
Block a user