feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities

This commit is contained in:
2026-09-16 15:17:51 +05:30
parent 42424dff8b
commit d132bbe716
10 changed files with 1470 additions and 201 deletions
@@ -187,6 +187,124 @@
}
<div class="ckyc-upload-grid">
<!-- ID Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">badge</mat-icon>
<span>{{ 'labels.inputs.ID Proof Document' | translate }}</span>
</div>
@if (existing.ID_PROOF.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ID_PROOF.source }}</span>
@if (existing.ID_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ID_PROOF.previewUrl && isImage('ID_PROOF')) {
<img [src]="existing.ID_PROOF.previewUrl" alt="ID proof" class="ckyc-existing__thumb" />
}
@if (existing.ID_PROOF.previewUrl) {
<button mat-stroked-button type="button" class="ckyc-existing__view" (click)="openDocument('ID_PROOF')">
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ID_PROOF.name }}</span>
@if (existing.ID_PROOF.documentKey) {
<span class="ckyc-existing__key">{{ existing.ID_PROOF.documentKey }}</span>
}
@if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.ID_PROOF.fileCount }}
{{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('ID_PROOF')"
>
<fa-icon [icon]="existing.ID_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ID_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#idProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ID_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="idProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="idProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!idProofFile || uploading['ID_PROOF']"
(click)="uploadDocument('ID_PROOF')"
>
@if (uploading['ID_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ID_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ID_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ID_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ID_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ID_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
<!-- Address Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
@@ -202,8 +320,25 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) {
<img [src]="existing.ADDRESS_PROOF.previewUrl" alt="Address proof" class="ckyc-existing__thumb" />
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -308,8 +443,25 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) {
<img [src]="existing.PHOTO_PROOF.previewUrl" alt="Photo proof" class="ckyc-existing__thumb" />
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -413,10 +565,22 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
@@ -509,6 +673,9 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@@ -518,6 +685,17 @@
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -638,6 +638,35 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -9,7 +9,7 @@
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
import { Subject, takeUntil, catchError, of } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -28,8 +28,60 @@ interface ExistingArtefact {
documentKey?: string;
fileCount?: number;
expanded?: boolean;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
}
/**
* Multipart `name` / `description` sent for each CKYC artefact type.
*
* The platform has no CKYC-specific document API; these are stored through the generic
* DocumentApiResource, whose contract carries no document-type field. The artefact type therefore
* travels in `name`, which is the same mechanism the client signature already uses - so
* `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that
* loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would
* orphan every previously uploaded signature.
*/
export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
const CKYC_DOCUMENT_META: Record<CkycDocumentType, { name: string; description: string }> = {
ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' },
ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' },
PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' },
CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' },
CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' }
};
const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[];
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check
* lives in onFileSelect().
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Types whose card renders an inline <img> preview, so a PDF would not display. */
const IMAGE_ONLY_TYPES: CkycDocumentType[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -79,8 +131,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
return this.panVerified && this.aadhaarVerified;
}
/** Pre-existing artefacts detected from client data */
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
/** CKYC artefacts already uploaded for this client, keyed by artefact type. */
existing: Record<CkycDocumentType, ExistingArtefact> = {
ID_PROOF: { present: false },
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
@@ -88,6 +141,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
};
/** File references */
idProofFile: File | null = null;
addressProofFile: File | null = null;
photoProofFile: File | null = null;
clientImageFile: File | null = null;
@@ -95,6 +149,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Per-document upload state */
uploading: { [key: string]: boolean } = {
ID_PROOF: false,
ADDRESS_PROOF: false,
PHOTO_PROOF: false,
CLIENT_IMAGE: false,
@@ -106,6 +161,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Per-document error messages */
uploadErrors: { [key: string]: string | null } = {
ID_PROOF: null,
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
@@ -207,98 +263,110 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
/**
* Fetch photo (profile image), signature (client document),
* ID proof and address proof (client identifiers) so already-uploaded
* artefacts are shown here instead of forcing re-upload.
* Loads every CKYC artefact from the client's documents (`m_document`), which is the single
* source of truth for this screen: each card matches the `name` its own upload writes, so what
* the checker sees is exactly what CKYC stored.
*
* Deliberately does NOT read the profile image or client identifiers any more. Those belong to
* client registration and the Identities section; reading them here showed artefacts this screen
* had never uploaded, and hid the ones it had. Those other screens are untouched.
*
* A client can accumulate several rows for one artefact (older uploads pre-date replacement, and
* the signature is also written by client registration), so the newest row - highest `id` - wins.
*/
private loadExistingArtefacts(): void {
this.checkingExisting = true;
forkJoin({
photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ photo, documents, identifiers, proofTypes }) => {
this.clientsService
.getClientDocuments(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of([]))
)
.subscribe((documents: any) => {
this.checkingExisting = false;
// 1. Photo / image of client — from m_client.image_id
if (photo && typeof photo === 'string' && photo.length > 0) {
this.existing.CLIENT_IMAGE = {
present: true,
source: 'Client registration',
previewUrl: photo,
name: 'Profile photo'
};
}
const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []);
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
// for clients who re-cropped/replaced their signature before uploads became single-slot,
// so pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.existing.CLIENT_SIGNATURE = {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id
};
// Fetch blob for preview
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
}
},
error: () => undefined
});
}
for (const type of CKYC_DOCUMENT_TYPES) {
const expectedName = CKYC_DOCUMENT_META[type].name;
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
const doc = list
.filter((d: any) => d?.name === expectedName)
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0];
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
for (const identity of idList) {
const docTypeId = String(identity?.documentType?.id ?? '');
const isActive =
!identity?.status ||
identity?.status?.code === 'clientIdentifierStatusType.active' ||
identity?.status === 'clientIdentifierStatusType.active' ||
identity?.status === 'Active';
if (!isActive) continue;
const proofName = identity?.documentType?.name || 'Identifier';
// Address proof documentKey is auto-generated as "<ProofName> - <clientId>"
// in identities-tab; hide it when it's just that synthetic key.
const rawKey = identity?.documentKey || '';
const isSyntheticKey = rawKey.startsWith(`${proofName} - `);
const common = {
present: true,
source: 'Identities section',
name: proofName,
documentKey: isSyntheticKey ? '' : rawKey,
documentId: identity?.id,
fileCount: (identity?.documents || []).length
};
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
this.existing.PHOTO_PROOF = common;
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
this.existing.ADDRESS_PROOF = common;
if (!doc) {
// No upload yet for this artefact - leave the card in its empty state.
this.existing[type] = { present: false };
continue;
}
this.existing[type] = {
present: true,
source: 'CKYC upload',
name: doc.fileName || expectedName,
documentId: doc.id,
mimeType: doc.type
};
this.loadPreview(type, doc.id);
}
});
}
/**
* Fetches the stored file so the card can show a thumbnail. A failure here is reported on the
* card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in
* the content store, and silently showing "Already available" with no image hides that.
*/
private loadPreview(type: CkycDocumentType, documentId: string | number): void {
this.clientsService
// silent: a missing stored file returns 500, and the card below already reports that. Without
// this the global error interceptor pops an "Internal Server Error" modal on every page load
// and after every upload, for a failure the user cannot act on.
.downloadClientDocument(this.clientId, String(documentId), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing[type].previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the card
// would sit there claiming "Already available" with no thumbnail and no explanation.
this.existing[type].previewUnavailable = true;
}
},
error: () => {
this.existing[type].previewUnavailable = true;
}
});
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(documentType: CkycDocumentType): boolean {
const entry = this.existing[documentType];
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(documentType: CkycDocumentType): void {
const url = this.existing[documentType]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
toggleReplace(documentType: string): void {
const entry = (this.existing as any)[documentType];
if (entry) entry.expanded = !entry.expanded;
@@ -309,11 +377,38 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.selectedAccountType = this.selectedAccount?.accountType || '';
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
/** Extensions a given card can both store and render. */
private allowedExtensions(documentType: string): string[] {
return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File, documentType: string): string | null {
const allowed = this.allowedExtensions(documentType);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
private setFile(documentType: string, file: File | null): void {
switch (documentType) {
case 'ID_PROOF':
this.idProofFile = file;
break;
case 'ADDRESS_PROOF':
this.addressProofFile = file;
break;
@@ -327,11 +422,28 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.clientSignatureFile = file;
break;
}
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
const error = this.validationError(file, documentType);
if (error) {
this.setFile(documentType, null);
this.uploadErrors[documentType] = error;
event.target.value = '';
return;
}
this.setFile(documentType, file);
this.uploadErrors[documentType] = null;
}
getFile(documentType: string): File | null {
switch (documentType) {
case 'ID_PROOF':
return this.idProofFile;
case 'ADDRESS_PROOF':
return this.addressProofFile;
case 'PHOTO_PROOF':
@@ -345,38 +457,64 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
}
uploadDocument(documentType: string): void {
uploadDocument(documentType: CkycDocumentType): void {
const file = this.getFile(documentType);
if (!file) return;
this.uploading[documentType] = true;
this.uploadErrors[documentType] = null;
// DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and
// `description` (file + description mandatory). There is no document-type field in that
// contract, so the artefact type is carried in `name` - which is also the key
// loadExistingArtefacts() matches on when reading the document back.
const meta = CKYC_DOCUMENT_META[documentType];
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', documentType);
formData.append('name', meta.name);
formData.append('description', meta.description);
this.clientsService
.uploadCkycDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.alertService.alert({
type: 'success',
message: response?.message || 'Document uploaded successfully.'
});
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
// Replace in place when this artefact already has a usable document, so a re-upload updates the
// existing row (same document id, same content-store location) instead of stacking up another
// copy. Falls back to create for the first upload of an artefact.
//
// Deliberately create instead of replace when the stored file is missing: the backend's update
// path writes the new content relative to the row's existing `location` and never calls
// setLocation(), so replacing a row whose file is already gone writes the upload to a fresh
// folder while the row keeps pointing at the missing one - the artefact would stay broken and
// the user would get a success message. Creating a new row repairs the card instead.
const entry = this.existing[documentType];
const replacing = !!entry?.documentId && !entry?.previewUnavailable;
const existingId = entry?.documentId;
const request$ = replacing
? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData)
: this.clientsService.uploadCkycDocument(this.clientId, formData);
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.setFile(documentType, null);
this.alertService.alert({
type: 'success',
message:
response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.')
});
// Re-read from m_document so the card reflects what was actually stored, rather than
// assuming the write succeeded exactly as sent.
this.loadExistingArtefacts();
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
}
ngOnDestroy(): void {
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**