feat: add database export, welcome kit dialog, and DIP/sanctions updates
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:52:28 +05:30
co-authored by Claude Opus 5
parent c18219fce5
commit 7ef75c902d
33 changed files with 3042 additions and 208 deletions
@@ -0,0 +1,219 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<fa-icon icon="database" class="header-icon"></fa-icon>
<h2>Database Export</h2>
</div>
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
<div class="warning-banner">
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
<div>
<strong>This export contains the complete database</strong>
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
for a single download — taking another copy means raising a new request. Every request, approval and download is
recorded.
</div>
</div>
<!-- Raise a request -->
<div class="request-card" [formGroup]="requestForm">
<h3 class="request-card__title">Request a Full Export</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Reason for this export</mat-label>
<textarea
matInput
formControlName="reason"
rows="3"
maxlength="1000"
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
required
></textarea>
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
Please give at least 10 characters of context
</mat-error>
</mat-form-field>
<div class="request-card__actions">
<button
mat-flat-button
color="primary"
type="button"
[disabled]="requestForm.invalid || submitting"
(click)="submitRequest()"
>
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
</button>
</div>
</div>
<!-- Request history -->
<div class="filters-row">
<div class="filter-chips">
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
<fa-icon icon="list" class="chip-icon"></fa-icon> All
</button>
<button
mat-stroked-button
class="chip-pending"
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
(click)="filterByStatus('PENDING_APPROVAL')"
>
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
</button>
<button
mat-stroked-button
class="chip-approved"
[class.active-filter]="activeStatus === 'APPROVED'"
(click)="filterByStatus('APPROVED')"
>
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
</button>
<button
mat-stroked-button
class="chip-rejected"
[class.active-filter]="activeStatus === 'REJECTED'"
(click)="filterByStatus('REJECTED')"
>
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
</button>
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
}
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
<td mat-cell *matCellDef="let row">
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let row" class="reason-cell">
<span [matTooltip]="row.reason">{{ row.reason }}</span>
@if (row.rejectionReason) {
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="requestedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
<td mat-cell *matCellDef="let row">
<div class="stacked">
<span>{{ row.requestedByUsername || '—' }}</span>
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="approvedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
<td mat-cell *matCellDef="let row">
@if (row.approvedByUsername) {
<div class="stacked">
<span>{{ row.approvedByUsername }}</span>
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
</div>
} @else {
<span class="muted">—</span>
}
</td>
</ng-container>
<ng-container matColumnDef="generatedOn">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
<td mat-cell *matCellDef="let row">
@if (row.generatedOn) {
<div class="stacked">
<span>{{ row.generatedOn | date: 'medium' }}</span>
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
</div>
} @else {
<span class="muted">Not yet</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Action</th>
<td mat-cell *matCellDef="let row">
<div class="row-actions">
@if (isPending(row)) {
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
<button mat-stroked-button color="primary" (click)="approve(row)">
<fa-icon icon="check"></fa-icon> Approve
</button>
<button mat-stroked-button color="warn" (click)="reject(row)">
<fa-icon icon="times"></fa-icon> Reject
</button>
</ng-container>
}
@if (isDownloadable(row)) {
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
<fa-icon icon="download"></fa-icon>
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
</button>
}
@if (isWithheld(row)) {
<span
class="muted withheld-note"
matTooltip="Approving an export authorises it. Only {{
row.requestedByUsername || 'the requester'
}} can download the archive."
>
<fa-icon icon="lock"></fa-icon>
Requester only
</span>
}
@if (isSpent(row)) {
<span
class="muted withheld-note"
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
>
<fa-icon icon="check"></fa-icon>
Download used
</span>
}
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (!loading && dataSource.data.length === 0) {
<div class="no-data">
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
<p>No export requests yet.</p>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,213 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-icon {
color: #1074b9;
font-size: 20px;
}
}
/* The consequence of this screen is stated up front, not buried in a tooltip. */
.warning-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
margin-bottom: 20px;
border-radius: 8px;
background-color: rgb(180 83 9 / 7%);
border: 1px solid rgb(180 83 9 / 30%);
font-size: 13px;
line-height: 1.55;
color: #7c2d12;
&__icon {
color: #b45309;
font-size: 18px;
margin-top: 2px;
}
}
.request-card {
padding: 18px 20px;
margin-bottom: 22px;
border-radius: 10px;
background-color: #f7f9fc;
border: 1px solid #e3e7ee;
&__title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
}
&__actions {
display: flex;
justify-content: flex-end;
.chip-icon {
margin-right: 6px;
}
}
}
.full-width {
width: 100%;
}
.filters-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-chips {
display: flex;
align-items: center;
gap: 8px;
button {
font-size: 13px;
border-radius: 20px;
}
.chip-icon {
margin-right: 6px;
}
.active-filter {
background-color: #1074b9;
color: #fff;
border-color: #1074b9;
}
.chip-pending.active-filter {
background-color: #b45309;
border-color: #b45309;
}
.chip-approved.active-filter {
background-color: #388e3c;
border-color: #388e3c;
}
.chip-rejected.active-filter {
background-color: #d32f2f;
border-color: #d32f2f;
}
}
.result-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&.badge-pending {
background-color: rgb(180 83 9 / 12%);
color: #b45309;
}
&.badge-approved {
background-color: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&.badge-rejected {
background-color: rgb(211 47 47 / 12%);
color: #c62828;
}
&.badge-generated {
background-color: rgb(16 116 185 / 12%);
color: #1074b9;
}
}
.reason-cell {
max-width: 320px;
span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rejection-note {
margin-top: 2px;
font-size: 11px;
color: #c62828;
}
}
.stacked {
display: flex;
flex-direction: column;
line-height: 1.35;
}
.muted {
color: #6b7280;
font-size: 11px;
}
.row-actions {
display: flex;
gap: 8px;
align-items: center;
button {
font-size: 12px;
}
fa-icon {
margin-right: 5px;
}
}
/* Stands in for the download button on rows the viewer did not raise. */
.withheld-note {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-style: italic;
white-space: nowrap;
}
.no-data {
padding: 40px 0;
text-align: center;
color: #6b7280;
&-icon {
font-size: 30px;
margin-bottom: 10px;
opacity: 0.5;
}
}
@@ -0,0 +1,255 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DatabaseExportService } from './database-export.service';
/**
* Full database export, gated behind an approval. The requester states why the export is needed; an
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
*/
@Component({
selector: 'mifosx-database-export',
templateUrl: './database-export.component.html',
styleUrls: ['./database-export.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressBarModule,
MatTooltip,
NgClass,
DatePipe,
FaIconComponent
]
})
export class DatabaseExportComponent implements OnInit {
private service = inject(DatabaseExportService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private authenticationService = inject(AuthenticationService);
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
displayedColumns: string[] = [
'id',
'status',
'reason',
'requestedBy',
'approvedBy',
'generatedOn',
'actions'
];
dataSource = new MatTableDataSource<any>();
loading = false;
submitting = false;
downloadingId: number | null = null;
activeStatus: string | null = null;
requestForm: FormGroup = this.formBuilder.group({
reason: [
'',
[
Validators.required,
Validators.minLength(10),
Validators.maxLength(1000)
]
]
});
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
ngOnInit() {
this.load();
}
load() {
this.loading = true;
this.service.getRequests(this.activeStatus || undefined).subscribe({
next: (rows: any) => this.applyRows(rows || []),
error: () => this.applyRows([])
});
}
private applyRows(rows: any[]) {
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
filterByStatus(status: string | null) {
this.activeStatus = this.activeStatus === status ? null : status;
this.load();
}
submitRequest() {
if (this.requestForm.invalid) {
this.requestForm.markAllAsTouched();
return;
}
this.submitting = true;
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
next: () => {
this.submitting = false;
this.requestForm.reset({ reason: '' });
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => {
this.submitting = false;
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
}
});
}
approve(row: any) {
this.service.approve(row.id).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
});
}
reject(row: any) {
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
if (reason === null) {
return;
}
if (!reason.trim()) {
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
return;
}
this.service.reject(row.id, reason.trim()).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
});
}
download(row: any) {
this.downloadingId = row.id;
this.service.download(row.id).subscribe({
next: (blob: Blob) => {
this.downloadingId = null;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
link.click();
window.URL.revokeObjectURL(url);
// The status moves to GENERATED once the archive leaves the server, so reflect that.
this.load();
},
error: async (err: any) => {
this.downloadingId = null;
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
// as text before the platform's message can be pulled out of it.
let message = 'Download failed';
if (err?.error instanceof Blob) {
try {
const parsed = JSON.parse(await err.error.text());
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
} catch {
// Body was not JSON; keep the generic message.
}
} else {
message = this.errorMessage(err, message);
}
this.snackBar.open(message, 'Close', { duration: 6000 });
}
});
}
isPending(row: any): boolean {
return row?.status === 'PENDING_APPROVAL';
}
/**
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
* the request — and stated the reason it is recorded against — can take the archive, and only once.
*/
isDownloadable(row: any): boolean {
return row?.status === 'APPROVED' && this.isRequester(row);
}
isRequester(row: any): boolean {
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
}
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
isWithheld(row: any): boolean {
return row?.status === 'APPROVED' && !this.isRequester(row);
}
/** The approval has been spent; another copy means another request. */
isSpent(row: any): boolean {
return row?.status === 'GENERATED';
}
statusLabel(row: any): string {
switch (row?.status) {
case 'PENDING_APPROVAL':
return 'Awaiting Approval';
case 'APPROVED':
return 'Approved';
case 'REJECTED':
return 'Rejected';
case 'GENERATED':
return 'Downloaded';
default:
return row?.status || '—';
}
}
statusClass(row: any): string {
switch (row?.status) {
case 'APPROVED':
return 'badge-approved';
case 'REJECTED':
return 'badge-rejected';
case 'GENERATED':
return 'badge-generated';
default:
return 'badge-pending';
}
}
formatSize(bytes: number): string {
if (!bytes) {
return '—';
}
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
}
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
}
@@ -0,0 +1,45 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** Approval-gated full export of the tenant database. */
@Injectable({ providedIn: 'root' })
export class DatabaseExportService {
private http = inject(HttpClient);
getRequests(status?: string): Observable<any> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get('/databaseexport', { params });
}
/** @param reason why the export is needed — recorded against the request and inside the archive. */
requestExport(reason: string): Observable<any> {
return this.http.post('/databaseexport', { reason });
}
approve(requestId: number): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/approve`, {});
}
reject(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
}
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
download(requestId: number): Observable<Blob> {
return this.http.get(`/databaseexport/${requestId}/download`, {
responseType: 'blob' as 'json'
}) as Observable<Blob>;
}
}
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
const routes: Routes = [
Route.withShell([
@@ -56,6 +57,11 @@ const routes: Routes = [
component: HypothecationCertificateComponent,
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
},
{
path: 'letter-mgmt/database-export',
component: DatabaseExportComponent,
data: { title: 'Database Export', breadcrumb: 'Database Export' }
},
{
path: 'letter-mgmt/noc',
component: NocCertificateComponent,