feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
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

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
);
}
/**
* Split a stacked front-above-back scan back into its two sides.
*
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
* completely white rows across the full width — that band is the seam, and it is the only thing
* in the file that says where one side ends and the other begins.
*
* Resolves null when no such seam is found, which is the honest answer for an image that was not
* produced by that merge: better to show one scan than to cut a document in half at a guess.
*/
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
if (!blob.type.startsWith('image/')) {
return Promise.resolve(null);
}
type Sides = { front: Blob; back: Blob } | null;
return this.blobToImage(blob)
.then((img): Sides | Promise<Sides> => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx || canvas.width === 0 || canvas.height === 0) {
return null;
}
ctx.drawImage(img, 0, 0);
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
if (!seam) {
return null;
}
return Promise.all([
this.cropToBlob(img, canvas.width, 0, seam.start),
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
]).then(
([
front,
back
]): Sides => (front && back ? { front, back } : null)
);
})
.catch((): Sides => null);
}
/**
* The tallest run of all-white rows in the middle of the image.
*
* Confined to the middle half because a scan often has white margins at the top and bottom, and
* those are part of the document rather than the join between two of them.
*/
private findWhiteSeam(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): { start: number; end: number } | null {
const from = Math.floor(height * 0.25);
const to = Math.ceil(height * 0.75);
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
let bestStart = -1;
let bestLength = 0;
let runStart = -1;
for (let y = from; y <= to; y++) {
let white = y < to;
if (white) {
const row = ctx.getImageData(0, y, width, 1).data;
for (let x = 0; x < width; x += step) {
const i = x * 4;
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
white = false;
break;
}
}
}
if (white) {
if (runStart < 0) {
runStart = y;
}
} else if (runStart >= 0) {
if (y - runStart > bestLength) {
bestStart = runStart;
bestLength = y - runStart;
}
runStart = -1;
}
}
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
}
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
if (height <= 0) {
return Promise.resolve(null);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve(null);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
}
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
return this.fileToImage(blob as File);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);