From 972df2adfa7b9af0049913f03a9aa5ebe7c5dba9 Mon Sep 17 00:00:00 2001 From: elwessdev Date: Fri, 12 Jun 2026 06:00:06 +0100 Subject: [PATCH 1/2] code refactoring --- client/src/features/dashboard/Home/index.tsx | 2 +- .../{ => components}/NotificationDetail.tsx | 6 +- .../{ => components}/NotificationItem.tsx | 6 +- .../{ => components}/NotificationsPage.tsx | 8 +- .../NotificationsSkeleton.tsx | 2 +- .../dashboard/Notifications/index.tsx | 6 +- .../dashboard/Notifications/route.tsx | 2 +- .../Organization/Web-Hooks/WebhookDocs.tsx | 508 ----------- .../Organization/Web-Hooks/index.tsx | 840 ------------------ .../features/dashboard/Organization/index.tsx | 1 - .../features/dashboard/Organization/routes.ts | 6 - .../{ => components}/AddPatientModal.tsx | 4 +- .../Patients/{ => components}/PatientCard.tsx | 6 +- .../{ => components}/PatientDetail.tsx | 10 +- .../Patients/{ => components}/shared.tsx | 2 +- .../{ => components}/sub-components.tsx | 2 +- .../src/features/dashboard/Patients/index.tsx | 6 +- .../{ => components}/ComparisonTable.tsx | 2 +- .../Plans/{ => components}/FAQSection.tsx | 0 .../Plans/{ => components}/PageHeader.tsx | 0 .../Plans/{ => components}/PlanCard.tsx | 4 +- .../Plans/{ => components}/TrustStrip.tsx | 0 client/src/features/dashboard/Plans/index.tsx | 10 +- .../features/dashboard/Plans/primitives.tsx | 2 +- .../Reports/{ => components}/DetailCards.tsx | 0 .../Reports/{ => components}/ReportCard.tsx | 8 +- .../{ => components}/ReportsPageSkeleton.tsx | 0 .../Reports/{ => components}/SharePage.tsx | 12 +- .../Reports/{ => components}/TumorBadge.tsx | 2 +- .../{ => components}/details.components.tsx | 6 +- .../Reports/{ => components}/details.tsx | 12 +- .../Reports/{ => components}/details.utils.ts | 0 .../{ => components}/generateReportPDF.ts | 2 +- .../Reports/{ => components}/printReport.ts | 2 +- .../src/features/dashboard/Reports/index.tsx | 8 +- .../src/features/dashboard/Reports/route.tsx | 4 +- .../ScanResults/{ => components}/ConfBar.tsx | 0 .../{ => components}/DonutChart.tsx | 2 +- .../{ => components}/ReportCard.tsx | 14 +- .../{ => components}/ScanTableSkeleton.tsx | 2 +- .../{ => components}/StatusBadge.tsx | 2 +- .../{ => components}/TumorBadge.tsx | 2 +- .../{ => components}/components.tsx | 0 .../ScanResults/{ => components}/details.tsx | 12 +- .../features/dashboard/ScanResults/hooks.ts | 2 +- .../features/dashboard/ScanResults/index.tsx | 10 +- .../features/dashboard/ScanResults/route.ts | 2 +- .../UploadMRIScan/components/DetectCard.tsx | 28 + .../components/GuidelinesCard.tsx | 23 + .../components/ProcessingModal.tsx | 46 + .../components/RecentUploads.tsx | 50 ++ .../UploadMRIScan/components/UploadCard.tsx | 181 ++++ .../dashboard/UploadMRIScan/helpers.ts | 93 ++ .../dashboard/UploadMRIScan/index.tsx | 429 +-------- client/src/router.ts | 2 - 55 files changed, 537 insertions(+), 1854 deletions(-) rename client/src/features/dashboard/Notifications/{ => components}/NotificationDetail.tsx (97%) rename client/src/features/dashboard/Notifications/{ => components}/NotificationItem.tsx (95%) rename client/src/features/dashboard/Notifications/{ => components}/NotificationsPage.tsx (98%) rename client/src/features/dashboard/Notifications/{ => components}/NotificationsSkeleton.tsx (97%) delete mode 100644 client/src/features/dashboard/Organization/Web-Hooks/WebhookDocs.tsx delete mode 100644 client/src/features/dashboard/Organization/Web-Hooks/index.tsx rename client/src/features/dashboard/Patients/{ => components}/AddPatientModal.tsx (98%) rename client/src/features/dashboard/Patients/{ => components}/PatientCard.tsx (98%) rename client/src/features/dashboard/Patients/{ => components}/PatientDetail.tsx (98%) rename client/src/features/dashboard/Patients/{ => components}/shared.tsx (97%) rename client/src/features/dashboard/Patients/{ => components}/sub-components.tsx (96%) rename client/src/features/dashboard/Plans/{ => components}/ComparisonTable.tsx (99%) rename client/src/features/dashboard/Plans/{ => components}/FAQSection.tsx (100%) rename client/src/features/dashboard/Plans/{ => components}/PageHeader.tsx (100%) rename client/src/features/dashboard/Plans/{ => components}/PlanCard.tsx (98%) rename client/src/features/dashboard/Plans/{ => components}/TrustStrip.tsx (100%) rename client/src/features/dashboard/Reports/{ => components}/DetailCards.tsx (100%) rename client/src/features/dashboard/Reports/{ => components}/ReportCard.tsx (98%) rename client/src/features/dashboard/Reports/{ => components}/ReportsPageSkeleton.tsx (100%) rename client/src/features/dashboard/Reports/{ => components}/SharePage.tsx (98%) rename client/src/features/dashboard/Reports/{ => components}/TumorBadge.tsx (90%) rename client/src/features/dashboard/Reports/{ => components}/details.components.tsx (99%) rename client/src/features/dashboard/Reports/{ => components}/details.tsx (98%) rename client/src/features/dashboard/Reports/{ => components}/details.utils.ts (100%) rename client/src/features/dashboard/Reports/{ => components}/generateReportPDF.ts (99%) rename client/src/features/dashboard/Reports/{ => components}/printReport.ts (99%) rename client/src/features/dashboard/ScanResults/{ => components}/ConfBar.tsx (100%) rename client/src/features/dashboard/ScanResults/{ => components}/DonutChart.tsx (96%) rename client/src/features/dashboard/ScanResults/{ => components}/ReportCard.tsx (98%) rename client/src/features/dashboard/ScanResults/{ => components}/ScanTableSkeleton.tsx (98%) rename client/src/features/dashboard/ScanResults/{ => components}/StatusBadge.tsx (89%) rename client/src/features/dashboard/ScanResults/{ => components}/TumorBadge.tsx (89%) rename client/src/features/dashboard/ScanResults/{ => components}/components.tsx (100%) rename client/src/features/dashboard/ScanResults/{ => components}/details.tsx (98%) create mode 100644 client/src/features/dashboard/UploadMRIScan/components/DetectCard.tsx create mode 100644 client/src/features/dashboard/UploadMRIScan/components/GuidelinesCard.tsx create mode 100644 client/src/features/dashboard/UploadMRIScan/components/ProcessingModal.tsx create mode 100644 client/src/features/dashboard/UploadMRIScan/components/RecentUploads.tsx create mode 100644 client/src/features/dashboard/UploadMRIScan/components/UploadCard.tsx create mode 100644 client/src/features/dashboard/UploadMRIScan/helpers.ts diff --git a/client/src/features/dashboard/Home/index.tsx b/client/src/features/dashboard/Home/index.tsx index 53bbe07..c7212b3 100644 --- a/client/src/features/dashboard/Home/index.tsx +++ b/client/src/features/dashboard/Home/index.tsx @@ -2,7 +2,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { message } from "antd"; import type { ApiReportDetail } from "../Reports/types"; -import { generateReportPDF } from "../Reports/generateReportPDF"; +import { generateReportPDF } from "../Reports/components/generateReportPDF"; import axiosInstance from "../../../lib/axios"; import { MdFileUpload, diff --git a/client/src/features/dashboard/Notifications/NotificationDetail.tsx b/client/src/features/dashboard/Notifications/components/NotificationDetail.tsx similarity index 97% rename from client/src/features/dashboard/Notifications/NotificationDetail.tsx rename to client/src/features/dashboard/Notifications/components/NotificationDetail.tsx index 7752c16..84df462 100644 --- a/client/src/features/dashboard/Notifications/NotificationDetail.tsx +++ b/client/src/features/dashboard/Notifications/components/NotificationDetail.tsx @@ -1,7 +1,7 @@ import { MdArrowBack } from "react-icons/md"; -import { C, TYPE_STYLES } from "./tokens"; -import type { Notification } from "./types"; -import { formatRelativeTime } from "./utils"; +import { C, TYPE_STYLES } from "../tokens"; +import type { Notification } from "../types"; +import { formatRelativeTime } from "../utils"; import { useNavigate } from "@tanstack/react-router"; type Props = { diff --git a/client/src/features/dashboard/Notifications/NotificationItem.tsx b/client/src/features/dashboard/Notifications/components/NotificationItem.tsx similarity index 95% rename from client/src/features/dashboard/Notifications/NotificationItem.tsx rename to client/src/features/dashboard/Notifications/components/NotificationItem.tsx index f069833..669037a 100644 --- a/client/src/features/dashboard/Notifications/NotificationItem.tsx +++ b/client/src/features/dashboard/Notifications/components/NotificationItem.tsx @@ -1,6 +1,6 @@ -import { C, TYPE_STYLES } from "./tokens"; -import type { Notification } from "./types"; -import { formatRelativeTime } from "./utils"; +import { C, TYPE_STYLES } from "../tokens"; +import type { Notification } from "../types"; +import { formatRelativeTime } from "../utils"; type Props = { n: Notification; diff --git a/client/src/features/dashboard/Notifications/NotificationsPage.tsx b/client/src/features/dashboard/Notifications/components/NotificationsPage.tsx similarity index 98% rename from client/src/features/dashboard/Notifications/NotificationsPage.tsx rename to client/src/features/dashboard/Notifications/components/NotificationsPage.tsx index 2ff7ff8..27c40eb 100644 --- a/client/src/features/dashboard/Notifications/NotificationsPage.tsx +++ b/client/src/features/dashboard/Notifications/components/NotificationsPage.tsx @@ -8,11 +8,11 @@ import { MdArrowForward, } from "react-icons/md"; -import { C, TYPE_STYLES } from "./tokens"; -import type { Notification, NotifType } from "./types"; -import { useFetchNotifications, useReadNotification } from "./hooks"; +import { C, TYPE_STYLES } from "../tokens"; +import type { Notification, NotifType } from "../types"; +import { useFetchNotifications, useReadNotification } from "../hooks"; import { NotificationsSkeleton } from "./NotificationsSkeleton"; -import { formatRelativeTime } from "./utils"; +import { formatRelativeTime } from "../utils"; type ReadFilter = "all" | "unread" | "read"; diff --git a/client/src/features/dashboard/Notifications/NotificationsSkeleton.tsx b/client/src/features/dashboard/Notifications/components/NotificationsSkeleton.tsx similarity index 97% rename from client/src/features/dashboard/Notifications/NotificationsSkeleton.tsx rename to client/src/features/dashboard/Notifications/components/NotificationsSkeleton.tsx index 719b614..28421aa 100644 --- a/client/src/features/dashboard/Notifications/NotificationsSkeleton.tsx +++ b/client/src/features/dashboard/Notifications/components/NotificationsSkeleton.tsx @@ -1,4 +1,4 @@ -import { C } from "./tokens"; +import { C } from "../tokens"; const Bone = ({ w, h, r = 6 }: { w: string | number; h: number; r?: number }) => (
AppLayoutRoute, diff --git a/client/src/features/dashboard/Organization/Web-Hooks/WebhookDocs.tsx b/client/src/features/dashboard/Organization/Web-Hooks/WebhookDocs.tsx deleted file mode 100644 index 9708fa2..0000000 --- a/client/src/features/dashboard/Organization/Web-Hooks/WebhookDocs.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import { useState } from "react"; -import { message, Tooltip } from "antd"; -import { - MdContentCopy, - MdCheck, - MdBolt, - MdLock, - MdReplay, - MdMenuBook, -} from "react-icons/md"; -import { C, cardStyle } from "../tokens"; - -export type EventType = { - name: string; - description: string; - category: "Scan" | "Report" | "Patient" | "Organization"; -}; - -export const EVENT_TYPES: EventType[] = [ - { - name: "scan.created", - description: "A new scan has been uploaded and queued for processing.", - category: "Scan", - }, - { - name: "scan.completed", - description: "A scan has finished processing and results are available.", - category: "Scan", - }, - { - name: "scan.failed", - description: "A scan could not be processed. Payload includes a failure reason.", - category: "Scan", - }, - { - name: "report.generated", - description: "A diagnostic report has been generated and is ready to view.", - category: "Report", - }, - { - name: "patient.created", - description: "A new patient record was added to the organization.", - category: "Patient", - }, - { - name: "patient.updated", - description: "A patient record was modified.", - category: "Patient", - }, - { - name: "member.invited", - description: "A new member was invited to the organization.", - category: "Organization", - }, -]; - -const categoryColors: Record = { - Scan: { bg: "#dbeafe", fg: "#1d4ed8" }, - Report: { bg: "#ede9fe", fg: "#6d28d9" }, - Patient: { bg: "#dcfce7", fg: "#166534" }, - Organization: { bg: "#fef3c7", fg: "#92400e" }, -}; - -const samplePayload = `{ - "id": "evt_8f3a2d1c9e7b4a6f", - "type": "scan.completed", - "created": "2026-04-22T08:41:12.412Z", - "organization_id": "org_7b2e9c4a", - "data": { - "scan_id": "scn_4f8c1a9d", - "patient_id": "pat_2e6b3c7a", - "status": "completed", - "processing_ms": 8421, - "findings": { - "lesion_count": 2, - "confidence": 0.94, - "report_url": "https://app.tumorlens.com/reports/rpt_9c4e7a2b" - } - } -}`; - -const verifyExample = `import crypto from "node:crypto"; - -const SECRET = process.env.TUMORLENS_WEBHOOK_SECRET!; - -export function verify(req: Request): boolean { - const header = req.headers.get("TumorLens-Signature") ?? ""; - const [tsPart, sigPart] = header.split(","); - const timestamp = tsPart.replace("t=", ""); - const signature = sigPart.replace("v1=", ""); - - const payload = \`\${timestamp}.\${await req.text()}\`; - const expected = crypto - .createHmac("sha256", SECRET) - .update(payload) - .digest("hex"); - - return crypto.timingSafeEqual( - Buffer.from(expected), - Buffer.from(signature) - ); -}`; - -const sectionTitleStyle: React.CSSProperties = { - fontFamily: "Manrope, sans-serif", - fontWeight: 700, - fontSize: 15, - color: C.navy, - margin: 0, -}; - -const sectionSubtitleStyle: React.CSSProperties = { - fontSize: 12.5, - color: C.muted, - margin: "4px 0 0 0", -}; - -const labelStyle: React.CSSProperties = { - fontSize: 11, - fontWeight: 700, - color: C.muted, - letterSpacing: "0.8px", - textTransform: "uppercase", - margin: 0, -}; - -const thStyle: React.CSSProperties = { - fontSize: 11, - fontWeight: 700, - color: C.muted, - letterSpacing: "0.6px", - textTransform: "uppercase", - textAlign: "left", - padding: "10px 12px", - borderBottom: `1px solid ${C.border}`, - background: C.bg, -}; - -const tdStyle: React.CSSProperties = { - fontSize: 12.5, - color: C.text, - padding: "10px 12px", - borderBottom: `1px solid ${C.border}`, - verticalAlign: "top", -}; - -const CodeBlock = ({ code, lang }: { code: string; lang: string }) => { - const [copied, setCopied] = useState(false); - const handle = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - message.error("Failed to copy"); - } - }; - return ( -
-
- - {lang} - - - - -
-
-                {code}
-            
-
- ); -}; - -const InlineCode = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const InfoRow = ({ - Icon, - title, - children, -}: { - Icon: React.ComponentType<{ size?: number; color?: string }>; - title: string; - children: React.ReactNode; -}) => ( -
-
- -
-
-
- {title} -
-
- {children} -
-
-
-); - -export const WebhookDocs = () => { - return ( -
-
-
- -
-
-

- Webhook Reference -

-

- Available events, payload shape, and signature verification. -

-
-
- -
- {/* How it works */} -
- - Events are dispatched within seconds of occurring, as{" "} - POST requests with a JSON body. - - - Each request includes a TumorLens-Signature{" "} - header. Verify it with your signing secret to confirm authenticity. - - - Non-2xx responses are retried with exponential backoff for up to 24 - hours. Respond with 2xx quickly โ€” defer work to a queue. - -
- - {/* Event types */} -
-
-

Event types

-

- Subscribe to one or more events when creating a webhook. -

-
-
- - - - - - - - - - {EVENT_TYPES.map((e, i) => { - const isLast = i === EVENT_TYPES.length - 1; - const colors = categoryColors[e.category]; - return ( - - - - - - ); - })} - -
EventCategoryDescription
- - {e.name} - - - - {e.category} - - - {e.description} -
-
-
- - {/* Payload shape */} -
-
-

Payload shape

-

- Every event has the same top-level envelope. The{" "} - data field varies by event type. -

-
-
- Example โ€” scan.completed - -
-
- - {/* Signature verification */} -
-
-

Verifying the signature

-

- The TumorLens-Signature header carries a - timestamp and an HMAC-SHA256 of{" "} - {`{timestamp}.{raw_body}`} signed with - your webhook secret. -

-
-
- Node.js - -
-
- Tip: reject requests whose timestamp is more than 5 - minutes old to prevent replay attacks. -
-
-
-
- ); -}; diff --git a/client/src/features/dashboard/Organization/Web-Hooks/index.tsx b/client/src/features/dashboard/Organization/Web-Hooks/index.tsx deleted file mode 100644 index 064cb3b..0000000 --- a/client/src/features/dashboard/Organization/Web-Hooks/index.tsx +++ /dev/null @@ -1,840 +0,0 @@ -import { useState } from "react"; -import { Modal, Input, Select, Switch, message, Popconfirm, Tooltip } from "antd"; -import { - MdContentCopy, - MdCheck, - MdAdd, - MdEdit, - MdDelete, - MdWebhook, - MdSend, - MdCheckCircle, - MdErrorOutline, -} from "react-icons/md"; -import { C, cardStyle, cardHeaderStyle, cardBodyStyle } from "../tokens"; -import { WebhookDocs, EVENT_TYPES } from "./WebhookDocs"; - -type DeliveryStatus = "success" | "failed" | "pending"; - -type Webhook = { - id: string; - url: string; - description?: string; - events: string[]; - enabled: boolean; - secret: string; - createdAt: string; - lastDeliveryAt: string | null; - lastDeliveryStatus: DeliveryStatus | null; -}; - -const eventOptions = EVENT_TYPES.map((e) => ({ label: e.name, value: e.name })); - -const seedWebhooks: Webhook[] = [ - { - id: "wh_01", - url: "https://api.acme-health.com/hooks/tumorlens", - description: "Production ingest pipeline", - events: ["scan.completed", "report.generated"], - enabled: true, - secret: "whsec_8f3a2d1c9e7b4a6f5d8c2e1a9b7c4d6e", - createdAt: "2026-03-14T10:12:00.000Z", - lastDeliveryAt: "2026-04-22T08:41:12.000Z", - lastDeliveryStatus: "success", - }, - { - id: "wh_02", - url: "https://ops.acme-health.com/webhooks/alerts", - description: "Ops alerting", - events: ["scan.failed"], - enabled: true, - secret: "whsec_b7a4c2e8f1d93a6b5e4f2c8d1a7b3e9c", - createdAt: "2026-04-01T14:00:00.000Z", - lastDeliveryAt: "2026-04-20T19:23:44.000Z", - lastDeliveryStatus: "failed", - }, -]; - -const fmtDateTime = (d: string | null) => { - if (!d) return "โ€”"; - return new Date(d).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -}; - -const thStyle: React.CSSProperties = { - fontSize: 11, - fontWeight: 700, - color: C.muted, - letterSpacing: "0.8px", - textTransform: "uppercase", - textAlign: "left", - padding: "10px 14px", - borderBottom: `1px solid ${C.border}`, - background: C.bg, -}; - -const tdStyle: React.CSSProperties = { - fontSize: 13, - color: C.text, - padding: "14px", - borderBottom: `1px solid ${C.border}`, - verticalAlign: "middle", -}; - -const iconBtnStyle: React.CSSProperties = { - width: 30, - height: 30, - borderRadius: 7, - border: `1px solid ${C.border}`, - background: C.white, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - color: C.muted, - transition: "all 0.15s ease", -}; - -const primaryBtnStyle: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - gap: 6, - height: 34, - padding: "0 14px", - borderRadius: 8, - border: "none", - background: `linear-gradient(135deg, ${C.blue}, ${C.blueDk})`, - color: "white", - fontFamily: "Manrope, sans-serif", - fontWeight: 700, - fontSize: 12.5, - cursor: "pointer", - boxShadow: "0 3px 10px rgba(0,74,198,0.18)", - transition: "opacity 0.2s", -}; - -const eventChipStyle: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - fontFamily: "ui-monospace, SFMono-Regular, monospace", - fontSize: 11, - fontWeight: 600, - padding: "2px 7px", - borderRadius: 5, - background: "#eef2ff", - color: "#3730a3", - border: "1px solid rgba(79,70,229,0.15)", -}; - -const CopyButton = ({ value }: { value: string }) => { - const [copied, setCopied] = useState(false); - const handle = async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - message.error("Failed to copy"); - } - }; - return ( - - - - ); -}; - -const StatusPill = ({ enabled }: { enabled: boolean }) => ( - - - {enabled ? "Active" : "Paused"} - -); - -const DeliveryCell = ({ - at, - status, -}: { - at: string | null; - status: DeliveryStatus | null; -}) => { - if (!at || !status) { - return Never; - } - const color = - status === "success" ? "#16a34a" : status === "failed" ? "#dc2626" : C.muted; - const Icon = - status === "success" ? MdCheckCircle : status === "failed" ? MdErrorOutline : MdSend; - return ( -
- -
- {fmtDateTime(at)} - - {status} - -
-
- ); -}; - -const genSecret = () => { - const chars = "abcdef0123456789"; - let s = "whsec_"; - for (let i = 0; i < 32; i += 1) s += chars[Math.floor(Math.random() * chars.length)]; - return s; -}; - -export const WebHooksPage = () => { - const [webhooks, setWebhooks] = useState(seedWebhooks); - - const [createOpen, setCreateOpen] = useState(false); - const [form, setForm] = useState<{ - url: string; - description: string; - events: string[]; - }>({ url: "", description: "", events: [] }); - const [createdHook, setCreatedHook] = useState(null); - - const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState<{ - url: string; - description: string; - events: string[]; - }>({ url: "", description: "", events: [] }); - - const openCreate = () => { - setForm({ url: "", description: "", events: [] }); - setCreateOpen(true); - }; - - const handleCreate = () => { - const url = form.url.trim(); - if (!url) { - message.warning("Please enter an endpoint URL"); - return; - } - if (!/^https:\/\//i.test(url)) { - message.warning("URL must start with https://"); - return; - } - if (form.events.length === 0) { - message.warning("Select at least one event to subscribe to"); - return; - } - const next: Webhook = { - id: `wh_${Math.random().toString(36).slice(2, 8)}`, - url, - description: form.description.trim() || undefined, - events: form.events, - enabled: true, - secret: genSecret(), - createdAt: new Date().toISOString(), - lastDeliveryAt: null, - lastDeliveryStatus: null, - }; - setWebhooks((prev) => [next, ...prev]); - setCreateOpen(false); - setCreatedHook(next); - message.success("Webhook created"); - }; - - const openEdit = (w: Webhook) => { - setEditTarget(w); - setEditForm({ - url: w.url, - description: w.description ?? "", - events: w.events, - }); - }; - - const handleEdit = () => { - if (!editTarget) return; - const url = editForm.url.trim(); - if (!url) { - message.warning("Please enter an endpoint URL"); - return; - } - if (!/^https:\/\//i.test(url)) { - message.warning("URL must start with https://"); - return; - } - if (editForm.events.length === 0) { - message.warning("Select at least one event to subscribe to"); - return; - } - setWebhooks((prev) => - prev.map((w) => - w.id === editTarget.id - ? { - ...w, - url, - description: editForm.description.trim() || undefined, - events: editForm.events, - } - : w - ) - ); - setEditTarget(null); - message.success("Webhook updated"); - }; - - const handleToggle = (id: string, enabled: boolean) => { - setWebhooks((prev) => - prev.map((w) => (w.id === id ? { ...w, enabled } : w)) - ); - message.success(enabled ? "Webhook activated" : "Webhook paused"); - }; - - const handleDelete = (id: string) => { - setWebhooks((prev) => prev.filter((w) => w.id !== id)); - message.success("Webhook deleted"); - }; - - const handleTest = (w: Webhook) => { - message.loading({ content: `Sending test event to ${w.url}`, key: `t-${w.id}` }); - setTimeout(() => { - const ok = Math.random() > 0.15; - setWebhooks((prev) => - prev.map((x) => - x.id === w.id - ? { - ...x, - lastDeliveryAt: new Date().toISOString(), - lastDeliveryStatus: ok ? "success" : "failed", - } - : x - ) - ); - if (ok) { - message.success({ content: "Test event delivered (200 OK)", key: `t-${w.id}` }); - } else { - message.error({ content: "Test event failed (timeout)", key: `t-${w.id}` }); - } - }, 900); - }; - - return ( -
-
-
-
-

- Webhooks -

-

- Receive real-time event notifications by registering HTTPS endpoints. -

-
- -
- -
- {webhooks.length === 0 ? ( -
-
- -
-
- No webhooks yet -
-
- Register an HTTPS endpoint to receive events when scans complete, - reports are generated, and more. -
- -
- ) : ( -
- - - - - - - - - - - - {webhooks.map((w, i) => { - const isLast = i === webhooks.length - 1; - const cellBorder = isLast - ? "none" - : `1px solid ${C.border}`; - return ( - - - - - - - - ); - })} - -
EndpointEventsStatusLast DeliveryActions
-
- - {w.url} - - {w.description && ( - - {w.description} - - )} -
-
-
- {w.events.slice(0, 3).map((e) => ( - - {e} - - ))} - {w.events.length > 3 && ( - - - +{w.events.length - 3} - - - )} -
-
-
- - handleToggle(w.id, v)} - /> -
-
- - -
- - - - - - - handleDelete(w.id)} - > - - - - -
-
-
- )} -
-
- - - - {/* Create modal */} - setCreateOpen(false)} - okText="Create webhook" - destroyOnHidden - width={520} - > -
-
- - setForm((f) => ({ ...f, url: e.target.value }))} - autoFocus - /> -
- Must be HTTPS. We'll send events as POST requests with a - JSON body. -
-
- -
- - - setForm((f) => ({ ...f, description: e.target.value })) - } - /> -
- -
- - - setEditForm((f) => ({ ...f, url: e.target.value })) - } - autoFocus - /> -
-
- - - setEditForm((f) => ({ ...f, description: e.target.value })) - } - /> -
-
- - +
+ )} + + {/* File Preview */} + {previewFile && ( +
+
+
+
+ +
+
+

{previewFile.name}

+

{previewFile.size}

+
+
+ +
+
+ {previewFile.url ? ( + {previewFile.name} + ) : ( +
+ +

No preview available for DICOM

+
+ )} +
+ Preview ยท {previewFile.name} +
+
+
+ )} +
+ ); +}; diff --git a/client/src/features/dashboard/UploadMRIScan/helpers.ts b/client/src/features/dashboard/UploadMRIScan/helpers.ts new file mode 100644 index 0000000..4972350 --- /dev/null +++ b/client/src/features/dashboard/UploadMRIScan/helpers.ts @@ -0,0 +1,93 @@ +export interface ProcessingStep { + label: string; + status: "done" | "active" | "pending"; +} + +export const INITIAL_STEPS: ProcessingStep[] = [ + { label: "Uploading and preparing your image", status: "pending" }, + { label: "Analyzing brain tissue patterns", status: "pending" }, + { label: "Preparing your results", status: "pending" }, +]; + +export const DETECT_ITEMS = [ + { dot: "#3b82f6", name: "Pituitary", desc: "Affects the pituitary gland", rate: "99%" }, + { dot: "#f97316", name: "Meningioma", desc: "Arises from the meninges", rate: "93%" }, + { dot: "#ef4444", name: "Glioma", desc: "Most common primary brain tumor", rate: "89%" }, + { dot: "#22c55e", name: "No Tumor", desc: "Healthy brain tissue", rate: "96%" }, +]; + +export const TUMOR_TAG_COLOR: Record = { + glioma: "red", + meningioma: "orange", + pituitary: "blue", + notumor: "green", +}; + +export const TUMOR_LABEL: Record = { + glioma: "Glioma", + meningioma: "Meningioma", + pituitary: "Pituitary", + notumor: "No Tumor", +}; + +export const GUIDELINES = [ + { + bg: "bg-[#dae2fd]", + icon: "โŠž", + iconColor: "text-[#004ac6]", + title: "Use axial plane images", + desc: "Horizontal cross-sections of the brain produce the most accurate classification results.", + }, + { + bg: "bg-[#d1fae5]", + icon: "โœ“", + iconColor: "text-[#059669]", + title: "High resolution preferred", + desc: "Images above 224ร—224 pixels will yield more detailed and reliable outputs.", + }, + { + bg: "bg-[#fef3c7]", + icon: "โ˜…", + iconColor: "text-[#d97706]", + title: "Remove personal identifiers", + desc: "Ensure the image does not contain patient name, date of birth, or hospital ID overlays.", + }, + { + bg: "bg-[#fce7f3]", + icon: "โ„น", + iconColor: "text-[#9d174d]", + title: "For decision support only", + desc: "TumorLens is a clinical aid. All results must be reviewed and confirmed by a qualified radiologist.", + }, +]; + +export interface PreviewFile { + name: string; + size: string; + url: string | null; + file: File; + resolution: string; +} + +export interface RecentScan { + patient?: { full_name: string }; + name?: string; + updatedAt: string; + class: string; + scan_code: string; + uploadedById?: string; + scanImage?: { url?: string }; +} + +export function formatScanDate(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + const isYesterday = d.toDateString() === yesterday.toDateString(); + const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + if (isToday) return `Today ยท ${time}`; + if (isYesterday) return `Yesterday ยท ${time}`; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + ` ยท ${time}`; +} diff --git a/client/src/features/dashboard/UploadMRIScan/index.tsx b/client/src/features/dashboard/UploadMRIScan/index.tsx index 22c5e52..7f7f097 100644 --- a/client/src/features/dashboard/UploadMRIScan/index.tsx +++ b/client/src/features/dashboard/UploadMRIScan/index.tsx @@ -1,120 +1,36 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useAuthStore } from "../../../store/auth.store"; -import { Input, Select, Tag, Modal, Spin, message } from "antd"; +import { Input, Select, Spin, message } from "antd"; import { - UploadOutlined, - CloseOutlined, - CheckCircleFilled, LoadingOutlined, SafetyCertificateOutlined, - FileImageOutlined, PlusOutlined, UserOutlined, } from "@ant-design/icons"; import { useAnalyzeMRI } from "./hooks"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { usePatients } from "../Patients/hooks"; -import { AddPatientModal } from "../Patients/AddPatientModal"; +import { AddPatientModal } from "../Patients/components/AddPatientModal"; import { useFetchScanResults } from "../ScanResults/hooks"; - -// HELPERS -interface ProcessingStep { - label: string; - status: "done" | "active" | "pending"; -} - -const INITIAL_STEPS: ProcessingStep[] = [ - { label: "Uploading and preparing your image", status: "pending" }, - { label: "Analyzing brain tissue patterns", status: "pending" }, - { label: "Preparing your results", status: "pending" }, -]; - -const DETECT_ITEMS = [ - { dot: "#3b82f6", name: "Pituitary", desc: "Affects the pituitary gland", rate: "99%" }, - { dot: "#f97316", name: "Meningioma", desc: "Arises from the meninges", rate: "93%" }, - { dot: "#ef4444", name: "Glioma", desc: "Most common primary brain tumor", rate: "89%" }, - { dot: "#22c55e", name: "No Tumor", desc: "Healthy brain tissue", rate: "96%" }, -]; - -const TUMOR_TAG_COLOR: Record = { - glioma: "red", - meningioma: "orange", - pituitary: "blue", - notumor: "green", -}; - -const TUMOR_LABEL: Record = { - glioma: "Glioma", - meningioma: "Meningioma", - pituitary: "Pituitary", - notumor: "No Tumor", -}; - -function formatScanDate(dateStr: string): string { - const d = new Date(dateStr); - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - const isYesterday = d.toDateString() === yesterday.toDateString(); - const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); - if (isToday) return `Today ยท ${time}`; - if (isYesterday) return `Yesterday ยท ${time}`; - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + ` ยท ${time}`; -} - -const GUIDELINES = [ - { - bg: "bg-[#dae2fd]", - icon: "โŠž", - iconColor: "text-[#004ac6]", - title: "Use axial plane images", - desc: "Horizontal cross-sections of the brain produce the most accurate classification results.", - }, - { - bg: "bg-[#d1fae5]", - icon: "โœ“", - iconColor: "text-[#059669]", - title: "High resolution preferred", - desc: "Images above 224ร—224 pixels will yield more detailed and reliable outputs.", - }, - { - bg: "bg-[#fef3c7]", - icon: "โ˜…", - iconColor: "text-[#d97706]", - title: "Remove personal identifiers", - desc: "Ensure the image does not contain patient name, date of birth, or hospital ID overlays.", - }, - { - bg: "bg-[#fce7f3]", - icon: "โ„น", - iconColor: "text-[#9d174d]", - title: "For decision support only", - desc: "TumorLens is a clinical aid. All results must be reviewed and confirmed by a qualified radiologist.", - }, -]; - -function StepDot({ status }: { status: ProcessingStep["status"] }) { - if (status === "done") - return ; - if (status === "active") - return ; - return ; -} +import { INITIAL_STEPS } from "./helpers"; +import type { ProcessingStep, PreviewFile, RecentScan } from "./helpers"; +import { UploadCard } from "./components/UploadCard"; +import { ProcessingModal } from "./components/ProcessingModal"; +import { RecentUploads } from "./components/RecentUploads"; +import { DetectCard } from "./components/DetectCard"; +import { GuidelinesCard } from "./components/GuidelinesCard"; export const UploadScanPage = () => { const navigate = useNavigate(); const user = useAuthStore((state) => state.user); const incrementTotalScans = useAuthStore((state) => state.incrementTotalScans); - const fileInputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [previewFile, setPreviewFile] = useState<{ name: string; size: string; url: string | null; file: File; resolution: string } | null>(null); + const [previewFile, setPreviewFile] = useState(null); const [analysisName, setAnalysisName] = useState(""); const [steps, setSteps] = useState(INITIAL_STEPS); const [analyzing, setAnalyzing] = useState(false); const [note, setNote] = useState(""); const [patientIdSelected, setPatientIdSelected] = useState(null); - + const showPreview = useCallback((file: File) => { const ext = file.name.split(".").pop()?.toUpperCase() ?? ""; const isDicom = ext === "DCM" || ext === "DICOM"; @@ -132,40 +48,6 @@ export const UploadScanPage = () => { } }, []); - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) showPreview(file); - }; - - const handleFileSelect = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (!selectedFile) return; - - const maxSizeBytes = 20 * 1024 * 1024; - const extension = selectedFile.name.split(".").pop()?.toLowerCase() ?? ""; - const allowedExtensions = new Set(["jpg", "jpeg", "png", "dcm", "dicom"]); - const allowedMimeTypes = new Set(["image/jpeg", "image/png", "application/dicom"]); - - const isAllowedType = - allowedExtensions.has(extension) || allowedMimeTypes.has(selectedFile.type); - - if (!isAllowedType) { - message.error("Only JPEG, PNG, and DICOM files are supported."); - e.target.value = ""; - return; - } - - if (selectedFile.size > maxSizeBytes) { - message.error("File size must be 20MB or less."); - e.target.value = ""; - return; - } - - showPreview(selectedFile); - }; - // Start Analysis const {mutate: uploadScan, isPending: uploadScanLoading} = useAnalyzeMRI(); const startAnalysis = () => { @@ -263,7 +145,6 @@ export const UploadScanPage = () => { // Recent uploads (last 3 scans, doctor plan only) const { data: scanResults } = useFetchScanResults(); - type RecentScan = { patient: { full_name: string }; updatedAt: string; class: string; scan_code: string; uploadedById?: string }; const isDoctorMember = user?.org; const recentScans: RecentScan[] = (() => { const all = (scanResults as RecentScan[] | undefined) ?? []; @@ -306,133 +187,12 @@ export const UploadScanPage = () => {
{/* Upload Card */} -
-
-

MRI Image

-

Supported formats: JPEG, PNG, DICOM ยท Max size: 20MB

-
- - {/* Drop Zone */} - {remainingScans === 0 && user?.plan === "FREE" ? ( -
- message.warning({ - content: ( - - You've used all your free scans.{" "} - Upgrade Plan{" "} - to get unlimited scans, full reports, and patient management. - - ), - duration: 5, - }) - } - > -
- ๐Ÿ”’ -
-

Scan limit reached

-

- You've used all your free scans. {" "}
- Upgrade Plan{" "}
- for unlimited scans and advanced features. -

- e.stopPropagation()} - to="/plans" - className="inline-flex items-center gap-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-full px-5 py-2 text-sm font-bold transition-colors mt-1" - > - Upgrade Plan - -
- ) : ( -
{ e.preventDefault(); setIsDragging(true); }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - > -
- -
-

Drop your MRI image here

-

- Drag and drop your file, or{" "} - { e.stopPropagation(); fileInputRef.current?.click(); }} - > - browse from your computer - -

-
- {["JPEG", "PNG", "DICOM"].map((f) => ( - - {f} - - ))} -
-

Maximum file size: 20MB

- -
- )} - - {/* File Preview */} - {previewFile && ( -
-
-
-
- -
-
-

{previewFile.name}

-

{previewFile.size}

-
-
- -
-
- {previewFile.url ? ( - {previewFile.name} - ) : ( -
- -

No preview available for DICOM

-
- )} -
- Preview ยท {previewFile.name} -
-
-
- )} -
+ setPreviewFile(null)} + />
{/* Patient Info โ€” FREE plan teaser */} @@ -557,129 +317,19 @@ export const UploadScanPage = () => {
{/* Upload Guidelines โ€” Doctor plan: shown in left column below Analyze */} - {user?.plan != "FREE" && ( -
-
-

Upload Guidelines

-

For best results, follow these recommendations

-
-
- {GUIDELINES.map((g) => ( -
-
- {g.icon} -
-
-

{g.title}

-

{g.desc}

-
-
- ))} -
-
- )} + {user?.plan != "FREE" && }
{/* Recent Uploads */} - {user?.plan != "FREE" && ( -
-
-

Recent Uploads

-

Your last 3 scans

-
-
- {recentScans?.length === 0 ? ( -

No recent uploads

- ) : recentScans?.map((r: any, i) => ( - - -
- {r?.scanImage?.url ? ( - {r?.patient?.full_name - ) : ( - - - - - - )} -
-
-

{r?.patient?.full_name || r?.name || ""}

-

{formatScanDate(r.updatedAt)}

-
- - {TUMOR_LABEL[r.class] ?? r.class} - - - ))} -
-
- )} + {user?.plan != "FREE" && } {/* What We Detect */} -
-
-

What We Detect

-

Detects 4 scan outcomes to support faster, more confident clinical decisions.

-
-
- {DETECT_ITEMS.map((item) => ( -
- -
-

{item.name}

-

{item.desc}

-
- {item.rate} -
- ))} -
-
+ {/* Upload Guidelines โ€” FREE plan: shown in right column */} - {user?.plan === "FREE" && ( -
-
-

Upload Guidelines

-

For best results, follow these recommendations

-
-
- {GUIDELINES.map((g) => ( -
-
- {g.icon} -
-
-

{g.title}

-

{g.desc}

-
-
- ))} -
-
- )} + {user?.plan === "FREE" && }
@@ -692,38 +342,7 @@ export const UploadScanPage = () => { /> {/* โ”€โ”€ Processing Modal โ”€โ”€ */} - -
-
-

Analyzing MRI Scan

-

This usually takes under 15 seconds

-
- {steps.map((step, i) => ( -
- - - {step.label} - -
- ))} -
-
- +
); }; diff --git a/client/src/router.ts b/client/src/router.ts index 695fed3..ba1e1c6 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -21,7 +21,6 @@ import { OrganizationIndexRoute, OrganizationMembersRoute, OrganizationAPIKeysRoute, - // OrganizationWebHooksRoute, OrganizationAuditLogsRoute, } from "./features/dashboard/Organization/routes"; import { @@ -56,7 +55,6 @@ const routeTree = rootRoute.addChildren([ OrganizationIndexRoute, OrganizationMembersRoute, OrganizationAPIKeysRoute, - // OrganizationWebHooksRoute, OrganizationAuditLogsRoute, ]), ]), From d7ab440a3ed9c5ded32d932e3c6cc35c97f35e85 Mon Sep 17 00:00:00 2001 From: elwessdev Date: Fri, 12 Jun 2026 11:23:36 +0100 Subject: [PATCH 2/2] ci: add test & lint pipeline (Jest + Ruff) - Add CI workflow: Jest tests for server, Ruff lint for scan_model and scan_report, path-filtered per service, on dev/main pushes and PRs to main - Add AuthService unit tests (login, register, isOrgMember) - Configure Jest for otplib ESM deps and Prisma 7 generated client - Add Ruff as dev dependency with config in both Python services - Fix Ruff findings: import order, unused imports, long lines Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 79 +++++++++++++ scan_model/app.py | 11 +- scan_model/pyproject.toml | 16 +++ scan_model/uv.lock | 33 ++++++ scan_report/custom_types.py | 2 +- scan_report/load_data.py | 8 +- scan_report/main.py | 38 +++--- scan_report/pyproject.toml | 16 +++ scan_report/uv.lock | 33 ++++++ scan_report/vector_db.py | 8 +- server/package.json | 6 + server/src/auth/auth.service.spec.ts | 165 +++++++++++++++++++++++++++ 12 files changed, 388 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 server/src/auth/auth.service.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66f0171 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + changes: + name: Detect changed folders + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + rag: ${{ steps.filter.outputs.rag }} + model: ${{ steps.filter.outputs.model }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'server/**' + rag: + - 'scan_report/**' + model: + - 'scan_model/**' + + test-backend: + name: Backend โ€” Jest tests + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.backend == 'true' + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + lint-rag: + name: RAG โ€” Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.rag == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_report + + lint-model: + name: Model โ€” Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.model == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_model diff --git a/scan_model/app.py b/scan_model/app.py index a72bed2..4f20d97 100644 --- a/scan_model/app.py +++ b/scan_model/app.py @@ -1,10 +1,11 @@ import io -from fastapi import FastAPI, File, UploadFile -from torchvision import transforms + +import numpy as np +import timm import torch +from fastapi import FastAPI, File, UploadFile from PIL import Image -import timm -import numpy as np +from torchvision import transforms app = FastAPI() @@ -96,4 +97,4 @@ async def predict(file: UploadFile = File(...)): @app.get("/") def health(): - return {"status": "ok", "message": "Scan model service is running."} \ No newline at end of file + return {"status": "ok", "message": "Scan model service is running."} diff --git a/scan_model/pyproject.toml b/scan_model/pyproject.toml index 11a0a6f..a768861 100644 --- a/scan_model/pyproject.toml +++ b/scan_model/pyproject.toml @@ -14,3 +14,19 @@ dependencies = [ "torchvision>=0.26.0", "uvicorn>=0.44.0", ] + +[dependency-groups] +dev = [ + "ruff>=0.15.17", +] + +[tool.ruff] +target-version = "py314" +line-length = 120 +exclude = [".venv", "__pycache__"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.File", "fastapi.Depends", "fastapi.Query", "fastapi.Header", "fastapi.Form", "fastapi.Security"] diff --git a/scan_model/uv.lock b/scan_model/uv.lock index 219bf0f..796e324 100644 --- a/scan_model/uv.lock +++ b/scan_model/uv.lock @@ -663,6 +663,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + [[package]] name = "safetensors" version = "0.7.0" @@ -700,6 +725,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.135.3" }, @@ -712,6 +742,9 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.44.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.17" }] + [[package]] name = "setuptools" version = "81.0.0" diff --git a/scan_report/custom_types.py b/scan_report/custom_types.py index 3add4ce..9ecfa75 100644 --- a/scan_report/custom_types.py +++ b/scan_report/custom_types.py @@ -27,4 +27,4 @@ class ClassificationResult(pydantic.BaseModel): confidence: float all_scores: dict[str, float] - model_config = pydantic.ConfigDict(populate_by_name=True) \ No newline at end of file + model_config = pydantic.ConfigDict(populate_by_name=True) diff --git a/scan_report/load_data.py b/scan_report/load_data.py index 22bbb4a..2019604 100644 --- a/scan_report/load_data.py +++ b/scan_report/load_data.py @@ -1,7 +1,7 @@ -from openai import OpenAI -from llama_index.readers.file import PDFReader -from llama_index.core.node_parser import SentenceSplitter from dotenv import load_dotenv +from llama_index.core.node_parser import SentenceSplitter +from llama_index.readers.file import PDFReader +from openai import OpenAI load_dotenv() @@ -26,4 +26,4 @@ def embed_texts(texts: list[str]) -> list[list[float]]: input=texts, dimensions=EMBED_DIM, ) - return [item.embedding for item in response.data] \ No newline at end of file + return [item.embedding for item in response.data] diff --git a/scan_report/main.py b/scan_report/main.py index dcb3ffd..259fc2a 100644 --- a/scan_report/main.py +++ b/scan_report/main.py @@ -1,16 +1,22 @@ import logging -import uuid import os -from fastapi import FastAPI, HTTPException, Security, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from pydantic import BaseModel +import uuid + from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from langsmith import traceable from openai import OpenAI +from pydantic import BaseModel -from load_data import load_and_chunk_pdf, embed_texts +from custom_types import ( + ClassificationResult, + RAGChunkAndSrc, + RAGSearchResult, + RAGUpsertResult, +) +from load_data import embed_texts, load_and_chunk_pdf from vector_db import QdrantStorage -from custom_types import RAGQueryResult, RAGSearchResult, RAGUpsertResult, RAGChunkAndSrc, ClassificationResult load_dotenv() @@ -95,7 +101,6 @@ def query_pdf_pipeline(question: str, top_k: int = 5) -> dict: @traceable(name="Generate Scan Report") def generate_report_pipeline(classification: ClassificationResult, top_k: int = 5) -> dict: - import json try: return _generate_report_pipeline(classification, top_k) @@ -140,9 +145,12 @@ def _generate_report_pipeline(classification: ClassificationResult, top_k: int = "{\n" ' "title": "Short report title, e.g. \'Glioma โ€” High-Grade Classification Report\'",\n' ' "diagnosis": "Specific diagnosis string, e.g. \'High-Grade Glioma\'",\n' - ' "imaging_observations": "2-3 sentence paragraph describing the imaging observations for the Imaging Observations section.",\n' - ' "imaging_characteristics": "2-3 sentence paragraph describing imaging characteristics and how they support the classification.",\n' - ' "alert_message": "1-2 sentence clinical alert โ€” warn if tumor detected, reassure if no tumor but still advise radiologist review.",\n' + ' "imaging_observations": "2-3 sentence paragraph describing the imaging observations ' + 'for the Imaging Observations section.",\n' + ' "imaging_characteristics": "2-3 sentence paragraph describing imaging characteristics ' + 'and how they support the classification.",\n' + ' "alert_message": "1-2 sentence clinical alert โ€” warn if tumor detected, reassure ' + 'if no tumor but still advise radiologist review.",\n' ' "recommended_next_steps": [\n' ' {\n' ' "title": "Step title",\n' @@ -174,16 +182,18 @@ def _generate_report_pipeline(classification: ClassificationResult, top_k: int = scores_sorted = sorted( classification.all_scores.items(), key=lambda x: x[1], reverse=True ) - + differential = [] for score_label, score_value in scores_sorted: score_pct = round(score_value * 100, 1) is_detected = score_label == label likelihood = "Primary" if is_detected else ("Low" if score_pct > 10 else "Very Low") reasoning = ( - "Primary classification โ€” matches imaging morphology, enhancement pattern, and lesion characteristics." + "Primary classification โ€” matches imaging morphology, enhancement pattern, " + "and lesion characteristics." if is_detected - else f"Considered in differential; confidence score of {score_pct}% makes this diagnosis unlikely given the dominant imaging features." + else f"Considered in differential; confidence score of {score_pct}% makes this " + "diagnosis unlikely given the dominant imaging features." ) differential.append({ "label": _LABEL_DESCRIPTIONS.get(score_label, score_label).title(), @@ -252,4 +262,4 @@ async def health_check(): return { "status": "ok", "message": "Scan report service is running." - } \ No newline at end of file + } diff --git a/scan_report/pyproject.toml b/scan_report/pyproject.toml index 7edfed8..0501194 100644 --- a/scan_report/pyproject.toml +++ b/scan_report/pyproject.toml @@ -14,3 +14,19 @@ dependencies = [ "qdrant-client>=1.17.1", "uvicorn>=0.44.0", ] + +[dependency-groups] +dev = [ + "ruff>=0.15.17", +] + +[tool.ruff] +target-version = "py314" +line-length = 120 +exclude = [".venv", "__pycache__", "PDF"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.File", "fastapi.Depends", "fastapi.Query", "fastapi.Header", "fastapi.Form", "fastapi.Security"] diff --git a/scan_report/uv.lock b/scan_report/uv.lock index 28e254a..3bfb252 100644 --- a/scan_report/uv.lock +++ b/scan_report/uv.lock @@ -1500,6 +1500,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + [[package]] name = "scan-report" version = "0.1.0" @@ -1515,6 +1540,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.135.3" }, @@ -1527,6 +1557,9 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.44.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.17" }] + [[package]] name = "setuptools" version = "82.0.1" diff --git a/scan_report/vector_db.py b/scan_report/vector_db.py index 27fcf99..0db774b 100644 --- a/scan_report/vector_db.py +++ b/scan_report/vector_db.py @@ -1,7 +1,9 @@ -from qdrant_client import QdrantClient -from qdrant_client.models import VectorParams, Distance, PointStruct, Query import os + from dotenv import load_dotenv +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, PointStruct, VectorParams + load_dotenv() class QdrantStorage: @@ -44,4 +46,4 @@ def search(self, query_vector, top_k: int = 5): contexts.append(text) sources.add(source) - return {"contexts": contexts, "sources": list(sources)} \ No newline at end of file + return {"contexts": contexts, "sources": list(sources)} diff --git a/server/package.json b/server/package.json index ec6237b..0f7fdcd 100644 --- a/server/package.json +++ b/server/package.json @@ -94,6 +94,12 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(otplib|@otplib|@scure|@noble)/)" + ], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..e8c0fe9 --- /dev/null +++ b/server/src/auth/auth.service.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException, ConflictException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { JwtService } from '@nestjs/jwt'; +import { MailService } from '../Notifications/mail.service'; +import { NotificationsService } from '../Notifications/notifications.service'; + +describe('AuthService', () => { + let service: AuthService; + + const prismaMock = { + user: { + findUnique: jest.fn(), + }, + session: { + findMany: jest.fn(), + }, + orgMember: { + findFirst: jest.fn(), + }, + organization: { + findFirst: jest.fn(), + }, + }; + + const jwtMock = { + signAsync: jest.fn(), + }; + + const mailMock = { + sendMail: jest.fn(), + }; + + const notificationsMock = { + notificationsSettings: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prismaMock }, + { provide: JwtService, useValue: jwtMock }, + { provide: MailService, useValue: mailMock }, + { provide: NotificationsService, useValue: notificationsMock }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('login', () => { + it('throws ConflictException when email or password is missing', async () => { + await expect( + service.login({ email: '', password: '' }), + ).rejects.toThrow(ConflictException); + }); + + it('throws UnauthorizedException when user is not found', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + await expect( + service.login({ email: 'no@one.com', password: 'secret' }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException for a Google-only account', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'g@user.com', + password: null, + }); + + await expect( + service.login({ email: 'g@user.com', password: 'secret' }), + ).rejects.toThrow('This account uses Google sign-in'); + }); + + it('throws UnauthorizedException when password is wrong', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'a@b.com', + password: await bcrypt.hash('correct-password', 10), + is_active: true, + }); + + await expect( + service.login({ email: 'a@b.com', password: 'wrong-password' }), + ).rejects.toThrow('Invalid credentials'); + }); + + it('throws UnauthorizedException when the account is deactivated', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'a@b.com', + password: await bcrypt.hash('secret', 10), + is_active: false, + }); + + await expect( + service.login({ email: 'a@b.com', password: 'secret' }), + ).rejects.toThrow('Account is deactivated'); + }); + + it('returns twoFactorRequired when 2FA is enabled and no code is given', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'u1', + email: 'a@b.com', + password: await bcrypt.hash('secret', 10), + is_active: true, + twoFactorEnabled: true, + twoFactorSecret: 'SECRET', + }); + + const result = await service.login({ + email: 'a@b.com', + password: 'secret', + }); + + expect(result).toEqual({ twoFactorRequired: true }); + }); + }); + + describe('register', () => { + it('throws ConflictException when required fields are missing', async () => { + await expect( + service.register({ email: '', password: '', username: '' }), + ).rejects.toThrow(ConflictException); + }); + + it('throws ConflictException when email is already in use', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1' }); + + await expect( + service.register({ + email: 'taken@b.com', + password: 'secret', + username: 'someone', + }), + ).rejects.toThrow('Email already in use'); + }); + }); + + describe('isOrgMember', () => { + it('returns true when a membership exists', async () => { + prismaMock.orgMember.findFirst.mockResolvedValue({ id: 'm1' }); + + await expect(service.isOrgMember('u1', 'org1')).resolves.toBe(true); + }); + + it('returns false when no membership exists', async () => { + prismaMock.orgMember.findFirst.mockResolvedValue(null); + + await expect(service.isOrgMember('u1', 'org1')).resolves.toBe(false); + }); + }); +});