Compare commits

...
2 Commits
Author SHA1 Message Date
Gitead f712479882 infra: serve app over HTTPS via nginx + Let's Encrypt for LAN access
getDisplayMedia (screen capture) requires a secure context, so LAN
staff access now goes through nginx on a real trusted cert
(local-summit.mydentalofficemanagement.com, issued via certbot's
Cloudflare DNS-01 plugin) instead of plain HTTP. A separate public
hostname/nginx block is reserved for Twilio webhooks only, routed
through Cloudflare Tunnel so nothing else is exposed to the internet.

Also fixes the backend CORS allowlist for the new hostname, and stops
the Socket.IO client from bypassing nginx to hit the backend directly
on :5000 (which broke under TLS/mixed content).
2026-07-12 17:25:30 -04:00
Gitead 118315a04e feat: add screenshot capture to Copy Agent page
Staff can now capture their screen via the browser's Screen Capture
API, crop the result, name the file, and save it directly to a
selected patient's Cloud Storage attachments folder.
2026-07-12 17:25:12 -04:00
9 changed files with 336 additions and 113 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ NODE_ENV="development"
HOST=0.0.0.0
PORT=5000
CLOUDFLARE_HOST=
FRONTEND_URLS=http://localhost:3000
FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com
SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret'
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
+3
View File
@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
# Local HTTPS dev certs (machine-specific, generated via mkcert)
certs/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+1
View File
@@ -76,6 +76,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"socket.io-client": "^4.8.1",
+9 -5
View File
@@ -6,13 +6,17 @@
*/
import { io, Socket } from "socket.io-client";
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
// Use the env var when set; otherwise derive the backend URL from the current
// page's hostname so remote browsers (non-localhost) reach the server correctly.
// When served through nginx (any port other than Vite's own 3000), nginx already
// proxies /socket.io/ with WS upgrade support — connect same-origin so TLS and
// CORS are handled consistently with the rest of the app.
// When hitting Vite's dev server directly on :3000, connect straight to the
// backend instead, since Vite's own WS proxy fails on upgrade (unhandled
// AggregateError from engine.io's Promise.any() probe).
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND ||
`${window.location.protocol}//${window.location.hostname}:5000`;
(window.location.port === "3000"
? `${window.location.protocol}//${window.location.hostname}:5000`
: window.location.origin);
export const socket: Socket = io(SOCKET_URL, {
withCredentials: true,
+261 -55
View File
@@ -1,30 +1,187 @@
import { Copy, FileCheck, CreditCard, Shield, Zap } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Camera, Copy, RotateCcw, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { PatientTable } from "@/components/patients/patient-table";
import { Patient } from "@repo/db/types";
const CAPABILITIES = [
{
icon: <Shield className="h-5 w-5 text-teal-600" />,
title: "Eligibility Results",
description:
"Automatically read insurance eligibility information — coverage status, plan details, deductibles, and co-pays — from the insurance portal.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
title: "Claim Information",
description:
"Extract claim numbers, claim status updates, and denial reasons from the insurance portal so they can be reused elsewhere without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
title: "Insurance Payments",
description:
"Capture ERA / EOB payment details — amounts, adjustments, patient responsibility — straight from the source document or portal.",
},
];
function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const canvas = document.createElement("canvas");
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
const ctx = canvas.getContext("2d");
ctx?.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL("image/png");
}
export default function AiCopyAgentPage() {
const { toast } = useToast();
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [screenshot, setScreenshot] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
imgRef.current = e.currentTarget;
const { width, height } = e.currentTarget;
const fullCrop = centerCrop(
makeAspectCrop({ unit: "%", width: 100 }, width / height, width, height),
width,
height
);
setCrop(fullCrop);
setCompletedCrop({
unit: "px",
x: 0,
y: 0,
width,
height,
});
};
const stopStream = () => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
const handleCapture = async () => {
setIsCapturing(true);
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "monitor" },
});
streamRef.current = stream;
const video = document.createElement("video");
video.srcObject = stream;
video.muted = true;
await video.play();
await new Promise<void>((resolve) => {
if (video.readyState >= 2) resolve();
else video.onloadeddata = () => resolve();
});
for (let secondsLeft = 3; secondsLeft > 0; secondsLeft--) {
setCountdown(secondsLeft);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
setCountdown(null);
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
stopStream();
setScreenshot(canvas.toDataURL("image/png"));
setFileName(`screenshot_${Date.now()}`);
} catch (error) {
console.error("Error capturing screenshot:", error);
const name = error instanceof Error ? error.name : "";
const description =
name === "NotAllowedError"
? "Screen share permission was denied or the picker was cancelled."
: name === "NotFoundError"
? "No screen/window source was available to capture."
: !navigator.mediaDevices?.getDisplayMedia
? "Screen capture isn't supported in this browser."
: (error instanceof Error ? error.message : "Screen capture failed.");
toast({
title: "Screenshot failed",
description,
variant: "destructive",
});
stopStream();
} finally {
setIsCapturing(false);
setCountdown(null);
}
};
const handleRetake = () => {
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
handleCapture();
};
const handleSave = async () => {
if (!screenshot || !selectedPatient) return;
setIsSaving(true);
try {
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const response = await fetch(croppedImage);
const blob = await response.blob();
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
const patientName =
selectedPatient.firstName && selectedPatient.lastName
? `${selectedPatient.firstName} ${selectedPatient.lastName}`
: selectedPatient.firstName ?? `patient-${selectedPatient.id}`;
const formData = new FormData();
formData.append("patientId", String(selectedPatient.id));
formData.append("patientName", patientName);
formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
toast({
title: "Saved",
description: `Screenshot saved to ${patientName}'s attachments.`,
});
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
} catch (error) {
console.error("Error saving screenshot:", error);
toast({
title: "Error",
description: "Failed to save screenshot to patient attachments.",
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10">
<div className="container mx-auto space-y-6">
{/* Hero */}
<div className="text-center space-y-3">
@@ -36,43 +193,92 @@ export default function AiCopyAgentPage() {
An AI agent reads and copies data including images and brings them to this app
automatically. No manual input required.
</p>
<span className="inline-block bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full border border-amber-200">
Coming Soon
</span>
</div>
{/* What it will copy */}
<div className="space-y-3">
<h2 className="text-base font-semibold">What it will copy</h2>
<div className="grid gap-4 sm:grid-cols-3">
{CAPABILITIES.map((cap) => (
<Card key={cap.title}>
<CardContent className="py-5 space-y-2">
<div className="flex items-center gap-2">
{cap.icon}
<span className="text-sm font-medium">{cap.title}</span>
{/* Screenshot capture */}
<Card>
<CardHeader>
<CardTitle>Capture Screenshot</CardTitle>
<CardDescription>
Capture your screen and save it to a patient's attachments.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{screenshot ? (
<div className="space-y-4">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
>
<img
src={screenshot}
alt="Captured screenshot"
onLoad={handleImageLoad}
className="w-full rounded-lg border object-contain max-h-96"
/>
</ReactCrop>
<p className="text-xs text-muted-foreground">
Drag the corners to crop, or leave as-is to save the full screenshot.
</p>
<div className="space-y-1.5 max-w-sm">
<Label htmlFor="screenshot-filename">File name</Label>
<div className="flex items-center gap-1">
<Input
id="screenshot-filename"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="screenshot"
/>
<span className="text-sm text-muted-foreground">.png</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{cap.description}
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={handleRetake} disabled={isCapturing || isSaving}>
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save to Patient"}
</Button>
</div>
{!selectedPatient && (
<p className="text-xs text-muted-foreground">
Select a patient below to enable saving.
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
</div>
) : (
<Button onClick={handleCapture} disabled={isCapturing}>
<Camera className="h-4 w-4 mr-2" />
{countdown !== null
? `Capturing in ${countdown}...`
: isCapturing
? "Waiting for screen share..."
: "Take Screenshot"}
</Button>
)}
</CardContent>
</Card>
{/* Why */}
<Card className="border-violet-200 bg-violet-50/50">
<CardContent className="py-5 flex items-start gap-3">
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-violet-900">Why this matters</p>
<p className="text-sm text-violet-700 leading-relaxed">
Staff currently check eligibility or look up a claim on an insurance portal, then
manually copy every value into this app. This agent eliminates that step entirely —
one click pulls the data straight in.
</p>
</div>
{/* Patient search */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select the patient this screenshot belongs to.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowCheckbox={true}
onSelectPatient={setSelectedPatient}
/>
</CardContent>
</Card>
-50
View File
@@ -1,50 +0,0 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
server: {
host: env.HOST,
port: Number(env.PORT),
fs: {
allow: [".."],
},
allowedHosts: [
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
"192.168.0.94",
],
proxy: {
"/api": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
configure: (proxy) => {
proxy.on("proxyReq", (proxyReq, req) => {
const auth = req.headers["authorization"];
if (auth)
proxyReq.setHeader("Authorization", auth);
});
},
},
"/socket.io": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
changeOrigin: true,
ws: true,
},
},
},
resolve: {
extensions: [".mts", ".ts", ".tsx", ".mjs", ".js", ".jsx", ".json"],
alias: {
"@": path.resolve(__dirname, "src"),
"@repo/db/usedSchemas": path.resolve(__dirname, "../../packages/db/usedSchemas/browser.ts"),
"@repo/db/types": path.resolve(__dirname, "../../packages/db/types/index.ts"),
"@repo/db": path.resolve(__dirname, "../../packages/db"),
},
},
optimizeDeps: {
exclude: ["@repo/db"],
},
};
});
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
allowedHosts: [
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
"192.168.0.94",
"local-summit.mydentalofficemanagement.com",
],
proxy: {
"/api": {
+50 -2
View File
@@ -1,6 +1,17 @@
# ── LAN-only app access (staff) ──────────────────────────────────────────
# https://local-summit.mydentalofficemanagement.com
# DNS A record points at this office's private LAN IP; cert is a real
# Let's Encrypt cert (issued via certbot + Cloudflare DNS-01), so no CA
# needs to be installed on any staff PC. Restricted to the office subnet.
server {
listen 80;
server_name _;
listen 443 ssl;
server_name local-summit.mydentalofficemanagement.com;
ssl_certificate /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/privkey.pem;
allow 192.168.0.0/24;
deny all;
client_max_body_size 50m;
@@ -33,3 +44,40 @@ server {
proxy_set_header Host $host;
}
}
# ── Public Twilio webhooks only ──────────────────────────────────────────
# https://summit.mydentalofficemanagement.com, reached via Cloudflare Tunnel.
# Cloudflare terminates TLS at its edge and cloudflared forwards plain HTTP
# to this block, so no certificate is needed here. Nothing except the
# Twilio webhook path is exposed on this hostname.
server {
listen 80;
server_name summit.mydentalofficemanagement.com;
client_max_body_size 50m;
location /api/twilio/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
return 403;
}
}
# Catch-all: reject anything not matching the two hostnames above
# (e.g. plain http://<ip> or an unrecognized Host header).
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/local-summit.mydentalofficemanagement.com/privkey.pem;
return 403;
}
+10
View File
@@ -150,6 +150,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-image-crop": "^11.1.2",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"socket.io-client": "^4.8.1",
@@ -11994,6 +11995,15 @@
"react": "*"
}
},
"node_modules/react-image-crop": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",