Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f712479882 | ||
|
|
118315a04e |
+1
-1
@@ -2,7 +2,7 @@ NODE_ENV="development"
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=5000
|
PORT=5000
|
||||||
CLOUDFLARE_HOST=
|
CLOUDFLARE_HOST=
|
||||||
FRONTEND_URLS=http://localhost:3000
|
FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com
|
||||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||||
JWT_SECRET = 'dentalsecret'
|
JWT_SECRET = 'dentalsecret'
|
||||||
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
|
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Local HTTPS dev certs (machine-specific, generated via mkcert)
|
||||||
|
certs/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
|
"react-image-crop": "^11.1.2",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
|||||||
@@ -6,13 +6,17 @@
|
|||||||
*/
|
*/
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
|
|
||||||
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
|
// When served through nginx (any port other than Vite's own 3000), nginx already
|
||||||
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
|
// proxies /socket.io/ with WS upgrade support — connect same-origin so TLS and
|
||||||
// Use the env var when set; otherwise derive the backend URL from the current
|
// CORS are handled consistently with the rest of the app.
|
||||||
// page's hostname so remote browsers (non-localhost) reach the server correctly.
|
// 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 =
|
const SOCKET_URL =
|
||||||
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
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, {
|
export const socket: Socket = io(SOCKET_URL, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
|||||||
@@ -1,30 +1,187 @@
|
|||||||
import { Copy, FileCheck, CreditCard, Shield, Zap } from "lucide-react";
|
import { useRef, useState } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 = [
|
function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
|
||||||
{
|
const scaleX = image.naturalWidth / image.width;
|
||||||
icon: <Shield className="h-5 w-5 text-teal-600" />,
|
const scaleY = image.naturalHeight / image.height;
|
||||||
title: "Eligibility Results",
|
|
||||||
description:
|
const canvas = document.createElement("canvas");
|
||||||
"Automatically read insurance eligibility information — coverage status, plan details, deductibles, and co-pays — from the insurance portal.",
|
canvas.width = crop.width * scaleX;
|
||||||
},
|
canvas.height = crop.height * scaleY;
|
||||||
{
|
const ctx = canvas.getContext("2d");
|
||||||
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
|
ctx?.drawImage(
|
||||||
title: "Claim Information",
|
image,
|
||||||
description:
|
crop.x * scaleX,
|
||||||
"Extract claim numbers, claim status updates, and denial reasons from the insurance portal so they can be reused elsewhere without manual re-entry.",
|
crop.y * scaleY,
|
||||||
},
|
crop.width * scaleX,
|
||||||
{
|
crop.height * scaleY,
|
||||||
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
|
0,
|
||||||
title: "Insurance Payments",
|
0,
|
||||||
description:
|
canvas.width,
|
||||||
"Capture ERA / EOB payment details — amounts, adjustments, patient responsibility — straight from the source document or portal.",
|
canvas.height
|
||||||
},
|
);
|
||||||
];
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
export default function AiCopyAgentPage() {
|
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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10">
|
<div className="container mx-auto space-y-6">
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="text-center space-y-3">
|
<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
|
An AI agent reads and copies data including images and brings them to this app
|
||||||
automatically. No manual input required.
|
automatically. No manual input required.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* What it will copy */}
|
{/* Screenshot capture */}
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
<h2 className="text-base font-semibold">What it will copy</h2>
|
<CardHeader>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<CardTitle>Capture Screenshot</CardTitle>
|
||||||
{CAPABILITIES.map((cap) => (
|
<CardDescription>
|
||||||
<Card key={cap.title}>
|
Capture your screen and save it to a patient's attachments.
|
||||||
<CardContent className="py-5 space-y-2">
|
</CardDescription>
|
||||||
<div className="flex items-center gap-2">
|
</CardHeader>
|
||||||
{cap.icon}
|
<CardContent className="space-y-4">
|
||||||
<span className="text-sm font-medium">{cap.title}</span>
|
{screenshot ? (
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<ReactCrop
|
||||||
{cap.description}
|
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>
|
</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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Why */}
|
{/* Patient search */}
|
||||||
<Card className="border-violet-200 bg-violet-50/50">
|
<Card>
|
||||||
<CardContent className="py-5 flex items-start gap-3">
|
<CardHeader>
|
||||||
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" />
|
<CardTitle>Patient Records</CardTitle>
|
||||||
<div className="space-y-1">
|
<CardDescription>
|
||||||
<p className="text-sm font-medium text-violet-900">Why this matters</p>
|
Select the patient this screenshot belongs to.
|
||||||
<p className="text-sm text-violet-700 leading-relaxed">
|
</CardDescription>
|
||||||
Staff currently check eligibility or look up a claim on an insurance portal, then
|
</CardHeader>
|
||||||
manually copy every value into this app. This agent eliminates that step entirely —
|
<CardContent>
|
||||||
one click pulls the data straight in.
|
<PatientTable
|
||||||
</p>
|
allowCheckbox={true}
|
||||||
</div>
|
onSelectPatient={setSelectedPatient}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
|
...(env.VITE_CLOUDFLARE_HOST ? [env.VITE_CLOUDFLARE_HOST] : []),
|
||||||
"192.168.0.94",
|
"192.168.0.94",
|
||||||
|
"local-summit.mydentalofficemanagement.com",
|
||||||
],
|
],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
+50
-2
@@ -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 {
|
server {
|
||||||
listen 80;
|
listen 443 ssl;
|
||||||
server_name _;
|
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;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
@@ -33,3 +44,40 @@ server {
|
|||||||
proxy_set_header Host $host;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Generated
+10
@@ -150,6 +150,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
|
"react-image-crop": "^11.1.2",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
@@ -11994,6 +11995,15 @@
|
|||||||
"react": "*"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user