Skip to content

Commit 084a69c

Browse files
authored
Merge branch 'main' into dependabot/cargo/services/aml-engine/uuid-1.24.0
2 parents 9058a93 + ce578ef commit 084a69c

47 files changed

Lines changed: 5112 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: BIS Validation
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
bff:
14+
name: bff-validation
15+
runs-on: ubuntu-latest
16+
services:
17+
postgres:
18+
image: postgres:16-alpine
19+
env:
20+
POSTGRES_USER: bis_user
21+
POSTGRES_PASSWORD: bis_secure_2026
22+
POSTGRES_DB: bis_db
23+
ports:
24+
- 5432:5432
25+
options: >-
26+
--health-cmd "pg_isready -U bis_user -d bis_db"
27+
--health-interval 10s
28+
--health-timeout 5s
29+
--health-retries 5
30+
env:
31+
DATABASE_URL: postgresql://bis_user:bis_secure_2026@localhost:5432/bis_db
32+
JWT_SECRET: ci-session-secret-not-for-production
33+
KEYCLOAK_URL: https://auth.bis.invalid
34+
KEYCLOAK_REALM: bis
35+
KEYCLOAK_CLIENT_ID: bis-bff
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: pnpm/action-setup@v4
39+
- uses: actions/setup-node@v4
40+
with:
41+
node-version: 22
42+
cache: pnpm
43+
- name: Install dependencies
44+
run: pnpm install --frozen-lockfile
45+
- name: Initialize disposable PostgreSQL test schema
46+
run: pnpm drizzle-kit push --force
47+
- name: Type-check
48+
run: pnpm check --noEmit
49+
- name: Production build
50+
run: pnpm run build
51+
- name: Test
52+
run: pnpm test

.github/workflows/codeql.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CodeQL Security Analysis
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
schedule:
9+
- cron: "17 3 * * 1"
10+
11+
permissions:
12+
security-events: write
13+
packages: read
14+
actions: read
15+
contents: read
16+
17+
jobs:
18+
analyze:
19+
name: Analyze ${{ matrix.language }}
20+
runs-on: ubuntu-latest
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
language: [javascript-typescript, python, go, rust]
25+
steps:
26+
- name: Checkout source
27+
uses: actions/checkout@v4
28+
- name: Initialize CodeQL
29+
uses: github/codeql-action/init@v3
30+
with:
31+
languages: ${{ matrix.language }}
32+
- name: Build compiled-language targets
33+
uses: github/codeql-action/autobuild@v3
34+
- name: Analyze
35+
uses: github/codeql-action/analyze@v3
36+
with:
37+
category: "/language:${{ matrix.language }}"

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,3 @@ services/event-emitter/target/
119119
services/aml-engine/target/
120120
services/event-processor/target/
121121
.project-config.json
122-
.github/workflows/

client/src/components/BISLayout.tsx

Lines changed: 151 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
UserCheck, BarChart3, Key, Zap, CheckCheck, ArrowRight, Sun, Moon, ClipboardList, Wallet, UserSearch, Shield, BarChart2,
1212
BookOpen, Link2, Brain, Gavel, ClipboardCheck, SendToBack, Smartphone, Download, Link as LinkIcon, BellRing, ShieldAlert,
1313
FileBarChart, TrendingUp, Landmark, Scale, Lock, Server, Workflow, Database as DbIcon, ArrowLeftRight, Coins,
14-
Map
14+
Map, Loader2
1515
} from 'lucide-react';
1616
import { Badge } from '@/components/ui/badge';
1717
import { Button } from '@/components/ui/button';
@@ -23,6 +23,7 @@ import { useAuth } from '@/_core/hooks/useAuth';
2323
import { useEventStream } from '@/hooks/useEventStream';
2424
import { GlobalSearchBar } from '@/components/GlobalSearchBar';
2525
import { useSessionHeartbeat } from '@/hooks/useSessionHeartbeat';
26+
import { toast } from 'sonner';
2627

2728
// ─── Nav config ───────────────────────────────────────────────────────────────
2829

@@ -214,6 +215,9 @@ interface Notification {
214215
body: string;
215216
time: string;
216217
ref?: string;
218+
href?: string;
219+
source?: 'alert' | 'in_app';
220+
approvalId?: number;
217221
read: boolean;
218222
}
219223

@@ -275,12 +279,14 @@ function NotificationPanel({
275279
notifications,
276280
onMarkRead,
277281
onMarkAllRead,
282+
onForceCreditDecision,
278283
onClose,
279284
onNavigate,
280285
}: {
281286
notifications: Notification[];
282287
onMarkRead: (id: string) => void;
283288
onMarkAllRead: () => void;
289+
onForceCreditDecision: (approvalId: number, decision: 'approve' | 'reject') => void;
284290
onClose: () => void;
285291
onNavigate: (href: string) => void;
286292
}) {
@@ -350,14 +356,20 @@ function NotificationPanel({
350356
<p className="text-xs font-mono font-semibold text-foreground leading-tight">{notif.title}</p>
351357
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug line-clamp-2">{notif.body}</p>
352358
<div className="flex items-center gap-2 mt-1.5">
353-
{notif.ref && (
359+
{(notif.ref || notif.href) && (
354360
<button
355-
onClick={() => { onNavigate('/investigations'); onClose(); }}
361+
onClick={() => { onNavigate(notif.href ?? '/investigations'); onClose(); }}
356362
className="flex items-center gap-1 text-[10px] font-mono text-primary hover:text-primary/80 transition-colors"
357363
>
358-
<ArrowRight size={9} /> View {notif.ref}
364+
<ArrowRight size={9} /> {notif.href ? 'Review approval' : `View ${notif.ref}`}
359365
</button>
360366
)}
367+
{!notif.read && notif.approvalId && (
368+
<>
369+
<button onClick={() => onForceCreditDecision(notif.approvalId!, 'approve')} className="text-[10px] font-mono text-emerald-500 hover:text-emerald-400 transition-colors">Approve</button>
370+
<button onClick={() => onForceCreditDecision(notif.approvalId!, 'reject')} className="text-[10px] font-mono text-red-500 hover:text-red-400 transition-colors">Reject</button>
371+
</>
372+
)}
361373
{!notif.read && (
362374
<button
363375
onClick={() => onMarkRead(notif.id)}
@@ -388,6 +400,26 @@ function NotificationPanel({
388400
);
389401
}
390402

403+
function ForceCreditDecisionPrompt({ decision, onClose, onSubmit, isPending }: { decision: { approvalId: number; decision: 'approve' | 'reject' }; onClose: () => void; onSubmit: (note: string, totpCode: string) => void; isPending: boolean }) {
404+
const [note, setNote] = useState("");
405+
const [totpCode, setTotpCode] = useState("");
406+
const approving = decision.decision === 'approve';
407+
const valid = note.trim().length >= 10 && (!approving || /^\d{6}$/.test(totpCode));
408+
return (
409+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-label={approving ? "Approve Force Credit" : "Reject Force Credit"}>
410+
<div className="w-full max-w-md rounded-xl border bg-popover shadow-2xl">
411+
<div className="flex items-center justify-between border-b px-4 py-3"><div><h3 className="text-sm font-semibold">{approving ? 'Authorize Force Credit' : 'Reject Force Credit'}</h3><p className="text-xs text-muted-foreground">Approval request #{decision.approvalId}</p></div><Button variant="ghost" size="icon" onClick={onClose} disabled={isPending}><X size={15} /></Button></div>
412+
<div className="space-y-3 p-4">
413+
<label className="block text-xs font-medium">{approving ? 'Authorization note' : 'Rejection note'}<textarea value={note} onChange={(event) => setNote(event.target.value)} className="mt-1 min-h-20 w-full rounded-md border bg-background p-2 text-sm" placeholder="State the basis for this decision (minimum 10 characters)" /></label>
414+
{approving && <label className="block text-xs font-medium">Authenticator code<input value={totpCode} onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" autoComplete="one-time-code" className="mt-1 w-full rounded-md border bg-background p-2 font-mono tracking-[0.35em] text-sm" placeholder="000000" /></label>}
415+
{approving && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-300">A fresh six-digit code from your verified authenticator app is required. The server validates it before recording any ledger credit.</p>}
416+
</div>
417+
<div className="flex justify-end gap-2 border-t p-4"><Button variant="outline" onClick={onClose} disabled={isPending}>Cancel</Button><Button variant={approving ? 'default' : 'destructive'} disabled={!valid || isPending} onClick={() => onSubmit(note.trim(), totpCode)}>{isPending ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}{approving ? 'Approve with MFA' : 'Reject request'}</Button></div>
418+
</div>
419+
</div>
420+
);
421+
}
422+
391423
// ─── Main Layout ──────────────────────────────────────────────────────────────
392424

393425
interface BISLayoutProps {
@@ -403,6 +435,7 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
403435
const [sidebarOpen, setSidebarOpen] = useState(true);
404436
const [bellOpen, setBellOpen] = useState(false);
405437
const [notifications, setNotifications] = useState<Notification[]>([]);
438+
const [forceCreditDecision, setForceCreditDecision] = useState<{ approvalId: number; decision: 'approve' | 'reject' } | null>(null);
406439
const { theme, toggleTheme } = useTheme();
407440
const { user, isAuthenticated } = useAuth();
408441
// Keep session alive while the user is actively using the app
@@ -425,25 +458,51 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
425458
{ refetchInterval: 30_000, staleTime: 15_000 }
426459
);
427460
const { data: notifUnreadData } = trpc.notifications.unreadCount.useQuery(undefined, {
428-
refetchInterval: 60_000,
429-
staleTime: 30_000,
461+
refetchInterval: 5_000,
462+
staleTime: 0,
430463
});
464+
const { data: persistedNotificationsData } = trpc.notifications.list.useQuery(
465+
{ unreadOnly: false, limit: 20, offset: 0 },
466+
{ refetchInterval: 5_000, staleTime: 0 }
467+
);
431468

432-
// Sync live alerts into notification state
469+
// ── Live unreconciled count for Reconciliation badge ─────────────────────
470+
const { data: reconData } = trpc.admin.reconciliation.listUnreconciled.useQuery(
471+
{ limit: 1 },
472+
{ refetchInterval: 30_000, staleTime: 15_000 }
473+
);
474+
475+
// Merge live alerts with persisted user-scoped notifications. The persisted
476+
// branch is how designated approvers receive Force Credit requests.
433477
useEffect(() => {
434-
if (!alertsData) return;
435-
setNotifications(
436-
alertsData.map((a: any) => ({
478+
const alertNotifications: Notification[] = (alertsData ?? []).map((a: any) => ({
437479
id: String(a.id),
438480
severity: alertSeverityToNotif(a.severity),
439481
title: a.title,
440482
body: a.body ?? '',
441483
time: new Date(a.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
442484
ref: a.investigationRef ?? undefined,
485+
source: 'alert',
443486
read: !!a.acknowledged,
444-
}))
445-
);
446-
}, [alertsData]);
487+
}));
488+
const inAppNotifications: Notification[] = (persistedNotificationsData?.notifications ?? []).map((notification: any) => {
489+
const approvalId = notification.type === 'force_credit_approval_requested'
490+
? Number(new URL(notification.link ?? '', window.location.origin).searchParams.get('approvalId'))
491+
: NaN;
492+
return {
493+
id: `in-app-${notification.id}`,
494+
severity: notification.type === 'force_credit_approval_requested' ? 'high' : 'low',
495+
title: notification.title,
496+
body: notification.body ?? '',
497+
time: new Date(notification.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
498+
href: notification.link ?? undefined,
499+
source: 'in_app',
500+
approvalId: Number.isInteger(approvalId) && approvalId > 0 ? approvalId : undefined,
501+
read: !!notification.read,
502+
};
503+
});
504+
setNotifications([...inAppNotifications, ...alertNotifications]);
505+
}, [alertsData, persistedNotificationsData]);
447506

448507
// ── SSE event stream — instant invalidation on new alerts ─────────────────
449508
const utils = trpc.useUtils();
@@ -474,6 +533,22 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
474533
},
475534
});
476535

536+
// User-scoped PostgreSQL LISTEN/NOTIFY stream. The database remains the source
537+
// of truth; this event only invalidates cached dropdown data immediately.
538+
useEffect(() => {
539+
if (!isAuthenticated) return;
540+
const stream = new EventSource("/api/notifications/stream");
541+
const refreshNotifications = () => {
542+
utils.notifications.list.invalidate();
543+
utils.notifications.unreadCount.invalidate();
544+
};
545+
stream.addEventListener("notification", refreshNotifications);
546+
return () => {
547+
stream.removeEventListener("notification", refreshNotifications);
548+
stream.close();
549+
};
550+
}, [isAuthenticated, utils]);
551+
477552
// ── Web Push: register SW + request permission + register token ────────────
478553
const registerTokenMutation = trpc.push.registerToken.useMutation();
479554

@@ -529,17 +604,59 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
529604
const markReadMutation = trpc.alerts.acknowledge.useMutation({
530605
onSuccess: () => utils.alerts.list.invalidate(),
531606
});
607+
const markInAppReadMutation = trpc.notifications.markRead.useMutation({
608+
onSuccess: () => {
609+
utils.notifications.list.invalidate();
610+
utils.notifications.unreadCount.invalidate();
611+
},
612+
});
613+
const markAllInAppReadMutation = trpc.notifications.markAllRead.useMutation({
614+
onSuccess: () => {
615+
utils.notifications.list.invalidate();
616+
utils.notifications.unreadCount.invalidate();
617+
},
618+
});
619+
const dismissApprovalNotification = (approvalId: number) => {
620+
const notification = notifications.find((item) => item.approvalId === approvalId && !item.read);
621+
if (notification) markRead(notification.id);
622+
};
623+
const approveForceCreditMutation = trpc.admin.reconciliation.approveForceCredit.useMutation({
624+
onSuccess: (result) => {
625+
toast.success(`Force Credit approved and recorded as ${result.transferId}`);
626+
if (forceCreditDecision) dismissApprovalNotification(forceCreditDecision.approvalId);
627+
setForceCreditDecision(null);
628+
utils.admin.reconciliation.listForceCreditApprovals.invalidate();
629+
utils.notifications.list.invalidate();
630+
utils.notifications.unreadCount.invalidate();
631+
},
632+
onError: (error) => toast.error(error.message || "Force Credit approval failed"),
633+
});
634+
const rejectForceCreditMutation = trpc.admin.reconciliation.rejectForceCredit.useMutation({
635+
onSuccess: () => {
636+
toast.success("Force Credit request rejected");
637+
if (forceCreditDecision) dismissApprovalNotification(forceCreditDecision.approvalId);
638+
setForceCreditDecision(null);
639+
utils.admin.reconciliation.listForceCreditApprovals.invalidate();
640+
utils.notifications.list.invalidate();
641+
utils.notifications.unreadCount.invalidate();
642+
},
643+
onError: (error) => toast.error(error.message || "Force Credit rejection failed"),
644+
});
532645

533646
const markRead = (id: string) => {
534647
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
535-
markReadMutation.mutate({ id: parseInt(id, 10) });
648+
if (id.startsWith('in-app-')) {
649+
markInAppReadMutation.mutate({ id: parseInt(id.replace('in-app-', ''), 10) });
650+
} else {
651+
markReadMutation.mutate({ id: parseInt(id, 10) });
652+
}
536653
};
537654

538655
const markAllRead = () => {
539656
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
540-
notifications
541-
.filter(n => !n.read)
657+
notifications.filter(n => !n.read && n.source === 'alert')
542658
.forEach(n => markReadMutation.mutate({ id: parseInt(n.id, 10) }));
659+
if (notifications.some(n => !n.read && n.source === 'in_app')) markAllInAppReadMutation.mutate();
543660
};
544661

545662
// ── Build nav groups with live badges (filter admin-only items) ────────────
@@ -568,6 +685,9 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
568685
if (item.href === '/notifications' && notifUnreadData?.count) {
569686
return { ...item, badge: notifUnreadData.count };
570687
}
688+
if (item.href === '/payment-rails/reconciliation' && reconData?.stats?.total) {
689+
return { ...item, badge: reconData.stats.total, badgeVariant: 'destructive' as const };
690+
}
571691
return item;
572692
}),
573693
// Filter out admin-only items for non-admin users
@@ -726,10 +846,25 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
726846
notifications={notifications}
727847
onMarkRead={markRead}
728848
onMarkAllRead={markAllRead}
849+
onForceCreditDecision={(approvalId, decision) => setForceCreditDecision({ approvalId, decision })}
729850
onClose={() => setBellOpen(false)}
730851
onNavigate={navigate}
731852
/>
732853
)}
854+
{forceCreditDecision && (
855+
<ForceCreditDecisionPrompt
856+
decision={forceCreditDecision}
857+
onClose={() => setForceCreditDecision(null)}
858+
onSubmit={(note, totpCode) => {
859+
if (forceCreditDecision.decision === 'approve') {
860+
approveForceCreditMutation.mutate({ approvalId: forceCreditDecision.approvalId, approvalNote: note, totpCode });
861+
} else {
862+
rejectForceCreditMutation.mutate({ approvalId: forceCreditDecision.approvalId, rejectionNote: note });
863+
}
864+
}}
865+
isPending={approveForceCreditMutation.isPending || rejectForceCreditMutation.isPending}
866+
/>
867+
)}
733868
</div>
734869
);
735870
}

0 commit comments

Comments
 (0)