From e32e951dd3e2fd9543e86d9577e85f7d67c05deb Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 12 Nov 2025 00:54:02 +0530 Subject: [PATCH 01/28] fix(group title added) --- apps/Backend/src/routes/claims.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 1f22ac7..8e07f70 100644 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -126,17 +126,28 @@ router.post( responseType: "arraybuffer", }); - const groupTitle = "Claims"; + // Allowed keys as a literal tuple to derive a union type + const allowedKeys = [ + "INSURANCE_CLAIM", + "INSURANCE_CLAIM_PREAUTH", + ] as const; + type GroupKey = (typeof allowedKeys)[number]; + const isGroupKey = (v: any): v is GroupKey => + (allowedKeys as readonly string[]).includes(v); - // allowed keys - const allowedKeys = ["INSURANCE_CLAIM", "INSURANCE_CLAIM_PREAUTH"]; - if (!allowedKeys.includes(groupTitleKey)) { + if (!isGroupKey(groupTitleKey)) { return sendError( res, `Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}` ); } + const GROUP_TITLES: Record = { + INSURANCE_CLAIM: "Claims", + INSURANCE_CLAIM_PREAUTH: "Claims Preauth", + }; + const groupTitle = GROUP_TITLES[groupTitleKey]; + // βœ… Find or create PDF group for this claim let group = await storage.findPdfGroupByPatientTitleKey( parsedPatientId, From 03f08983e58c0963055d74f7095ce3e254ea1fb8 Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 12 Nov 2025 23:57:59 +0530 Subject: [PATCH 02/28] updated doc --- packages/db/docs/migration.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/db/docs/migration.md b/packages/db/docs/migration.md index 4137023..83ccff3 100644 --- a/packages/db/docs/migration.md +++ b/packages/db/docs/migration.md @@ -119,7 +119,7 @@ DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp # 🧠 Step 7 β€” Tips -- Use the same PostgreSQL version as the main PC. +- IMP: Use the same PostgreSQL version as the main PC. - currently more than v17. - For large databases, use parallel restore for speed: @@ -150,3 +150,11 @@ sudo apt install -y postgresql-client-17 ``` PGPASSWORD='mypassword' /usr/lib/postgresql/17/bin/pg_restore -v -U postgres -h localhost -C -d postgres ./backup.dump ``` + + +# If error comes while creating normal db with password: + +- then, give the postgres user its password. +``` +sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';" +``` From 394dbc359bd68c04256072cbcb648858c0366433 Mon Sep 17 00:00:00 2001 From: Potenz Date: Tue, 25 Nov 2025 00:08:30 +0530 Subject: [PATCH 03/28] updated prisma7 --- packages/db/prisma/prisma.config.ts | 15 +++++++++++++++ packages/db/prisma/schema.prisma | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/prisma.config.ts diff --git a/packages/db/prisma/prisma.config.ts b/packages/db/prisma/prisma.config.ts new file mode 100644 index 0000000..12e9b17 --- /dev/null +++ b/packages/db/prisma/prisma.config.ts @@ -0,0 +1,15 @@ +import "dotenv/config"; +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + // if prisma.config.ts sits in the same folder as schema.prisma: + schema: "schema.prisma", // or "prisma/schema.prisma" if config is at project root + + // required with the current Prisma types + engine: "classic", + + datasource: { + // use Prisma's env() helper instead of process.env for nicer types + url: env("DATABASE_URL"), + }, +}); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 41c7d2c..a7cb5f0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -16,7 +16,6 @@ generator zod { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model User { From 4ceffdd0734a2d63f8fe906956a5a3c9d0a17f06 Mon Sep 17 00:00:00 2001 From: Potenz Date: Tue, 25 Nov 2025 19:23:24 +0530 Subject: [PATCH 04/28] feat(ddma-eligibility) - v1 --- apps/Backend/.env.example | 1 + apps/Backend/package.json | 1 + apps/Backend/src/index.ts | 10 +- apps/Backend/src/routes/index.ts | 2 + .../Backend/src/routes/insuranceStatusDDMA.ts | 443 ++++++++++++++++++ .../seleniumDdmaInsuranceEligibilityClient.ts | 72 +++ apps/Backend/src/socket.ts | 53 +++ apps/Frontend/package.json | 1 + .../insurance-status/ddma-buton-modal.tsx | 426 +++++++++++++++++ .../src/pages/insurance-status-page.tsx | 25 +- apps/SeleniumService/agent.py | 77 ++- .../helpers_ddma_eligibility.py | 212 +++++++++ .../selenium_DDMA_eligibilityCheckWorker.py | 272 +++++++++++ package-lock.json | 300 +++++++++++- 14 files changed, 1881 insertions(+), 14 deletions(-) create mode 100644 apps/Backend/src/routes/insuranceStatusDDMA.ts create mode 100644 apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts create mode 100644 apps/Backend/src/socket.ts create mode 100644 apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx create mode 100644 apps/SeleniumService/helpers_ddma_eligibility.py create mode 100644 apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py diff --git a/apps/Backend/.env.example b/apps/Backend/.env.example index bf8de98..7f2e8e2 100644 --- a/apps/Backend/.env.example +++ b/apps/Backend/.env.example @@ -2,6 +2,7 @@ NODE_ENV="development" HOST=0.0.0.0 PORT=5000 FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000 +SELENIUM_AGENT_BASE_URL=http://localhost:5002 JWT_SECRET = 'dentalsecret' DB_HOST=localhost DB_USER=postgres diff --git a/apps/Backend/package.json b/apps/Backend/package.json index f2640c2..d2530f6 100644 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -27,6 +27,7 @@ "passport": "^0.7.0", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", + "socket.io": "^4.8.1", "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" diff --git a/apps/Backend/src/index.ts b/apps/Backend/src/index.ts index c2903c6..70e4375 100644 --- a/apps/Backend/src/index.ts +++ b/apps/Backend/src/index.ts @@ -1,5 +1,7 @@ import app from "./app"; import dotenv from "dotenv"; +import http from "http"; +import { initSocket } from "./socket"; dotenv.config(); @@ -11,7 +13,13 @@ const NODE_ENV = ( const HOST = process.env.HOST || "0.0.0.0"; const PORT = Number(process.env.PORT) || 5000; -const server = app.listen(PORT, HOST, () => { +// HTTP server from express app +const server = http.createServer(app); + +// Initialize socket.io on this server +initSocket(server); + +server.listen(PORT, HOST, () => { console.log( `βœ… Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}` ); diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 3b0ecbf..96a45e9 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -8,6 +8,7 @@ import patientDataExtractionRoutes from "./patientDataExtraction"; import insuranceCredsRoutes from "./insuranceCreds"; import documentsRoutes from "./documents"; import insuranceStatusRoutes from "./insuranceStatus"; +import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -27,6 +28,7 @@ router.use("/claims", claimsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/documents", documentsRoutes); router.use("/insurance-status", insuranceStatusRoutes); +router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusDDMA.ts b/apps/Backend/src/routes/insuranceStatusDDMA.ts new file mode 100644 index 0000000..5a5267e --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDDMA.ts @@ -0,0 +1,443 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { + forwardToSeleniumDdmaEligibilityAgent, + forwardOtpToSeleniumDdmaAgent, + getSeleniumDdmaSessionStatus, +} from "../services/seleniumDdmaInsuranceEligibilityClient"; +import fs from "fs/promises"; +import path from "path"; +import { emptyFolderContainingFile } from "../utils/emptyTempFolder"; +import forwardToPatientDataExtractorService from "../services/patientDataExtractorService"; +import { + InsertPatient, + insertPatientSchema, +} from "../../../../packages/db/types/patient-types"; +import { io } from "../socket"; + + +const router = Router(); + +/** Job context stored in memory by sessionId */ +interface DdmaJobContext { + userId: number; + insuranceEligibilityData: any; // parsed, enriched (includes username/password) +} + +const ddmaJobs: Record = {}; + +/** Utility: naive name splitter */ +function splitName(fullName?: string | null) { + if (!fullName) return { firstName: "", lastName: "" }; + const parts = fullName.trim().split(/\s+/).filter(Boolean); + const firstName = parts.shift() ?? ""; + const lastName = parts.join(" ") ?? ""; + return { firstName, lastName }; +} + +/** + * Ensure patient exists for given insuranceId. + */ +async function createOrUpdatePatientByInsuranceId(options: { + insuranceId: string; + firstName?: string | null; + lastName?: string | null; + dob?: string | Date | null; + userId: number; +}) { + const { insuranceId, firstName, lastName, dob, userId } = options; + if (!insuranceId) throw new Error("Missing insuranceId"); + + const incomingFirst = (firstName || "").trim(); + const incomingLast = (lastName || "").trim(); + + let patient = await storage.getPatientByInsuranceId(insuranceId); + + if (patient && patient.id) { + const updates: any = {}; + if ( + incomingFirst && + String(patient.firstName ?? "").trim() !== incomingFirst + ) { + updates.firstName = incomingFirst; + } + if ( + incomingLast && + String(patient.lastName ?? "").trim() !== incomingLast + ) { + updates.lastName = incomingLast; + } + if (Object.keys(updates).length > 0) { + await storage.updatePatient(patient.id, updates); + } + return; + } else { + const createPayload: any = { + firstName: incomingFirst, + lastName: incomingLast, + dateOfBirth: dob, + gender: "", + phone: "", + userId, + insuranceId, + }; + let patientData: InsertPatient; + try { + patientData = insertPatientSchema.parse(createPayload); + } catch (err) { + console.warn( + "Failed to validate patient payload in ddma insurance flow:", + err + ); + const safePayload = { ...createPayload }; + delete (safePayload as any).dateOfBirth; + patientData = insertPatientSchema.parse(safePayload); + } + await storage.createPatient(patientData); + } +} + +/** + * When Selenium finishes for a given sessionId, run your patient + PDF pipeline, + * and return the final API response shape. + */ +async function handleDdmaCompletedJob( + sessionId: string, + job: DdmaJobContext, + seleniumResult: any +) { + let createdPdfFileId: number | null = null; + const outputResult: any = {}; + const extracted: any = {}; + + const insuranceEligibilityData = job.insuranceEligibilityData; + + // 1) Extract name from PDF if available + if ( + seleniumResult?.pdf_path && + typeof seleniumResult.pdf_path === "string" && + seleniumResult.pdf_path.endsWith(".pdf") + ) { + try { + const pdfPath = seleniumResult.pdf_path; + const pdfBuffer = await fs.readFile(pdfPath); + + const extraction = await forwardToPatientDataExtractorService({ + buffer: pdfBuffer, + originalname: path.basename(pdfPath), + mimetype: "application/pdf", + } as any); + + if (extraction.name) { + const parts = splitName(extraction.name); + extracted.firstName = parts.firstName; + extracted.lastName = parts.lastName; + } + } catch (err: any) { + outputResult.extractionError = + err?.message ?? "Patient data extraction failed"; + } + } + + // 2) Create or update patient + const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim(); + if (!insuranceId) { + throw new Error("Missing memberId for ddma job"); + } + + const preferFirst = extracted.firstName; + const preferLast = extracted.lastName; + + await createOrUpdatePatientByInsuranceId({ + insuranceId, + firstName: preferFirst, + lastName: preferLast, + dob: insuranceEligibilityData.dateOfBirth, + userId: job.userId, + }); + + // 3) Update patient status + PDF upload + const patient = await storage.getPatientByInsuranceId( + insuranceEligibilityData.memberId + ); + + if (patient && patient.id !== undefined) { + const newStatus = + seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE"; + await storage.updatePatient(patient.id, { status: newStatus }); + outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; + + if ( + seleniumResult.pdf_path && + typeof seleniumResult.pdf_path === "string" && + seleniumResult.pdf_path.endsWith(".pdf") + ) { + const pdfBuffer = await fs.readFile(seleniumResult.pdf_path); + + const groupTitle = "Eligibility Status"; + const groupTitleKey = "ELIGIBILITY_STATUS"; + + let group = await storage.findPdfGroupByPatientTitleKey( + patient.id, + groupTitleKey + ); + if (!group) { + group = await storage.createPdfGroup( + patient.id, + groupTitle, + groupTitleKey + ); + } + if (!group?.id) { + throw new Error("PDF group creation failed: missing group ID"); + } + + const created = await storage.createPdfFile( + group.id, + path.basename(seleniumResult.pdf_path), + pdfBuffer + ); + if (created && typeof created === "object" && "id" in created) { + createdPdfFileId = Number(created.id); + } + outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`; + } else { + outputResult.pdfUploadStatus = + "No valid PDF path provided by Selenium, Couldn't upload pdf to server."; + } + } else { + outputResult.patientUpdateStatus = + "Patient not found or missing ID; no update performed"; + } + + // 4) Cleanup PDF temp folder + try { + if (seleniumResult && seleniumResult.pdf_path) { + await emptyFolderContainingFile(seleniumResult.pdf_path); + } + } catch (cleanupErr) { + console.error( + `[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path}]`, + cleanupErr + ); + } + + return { + patientUpdateStatus: outputResult.patientUpdateStatus, + pdfUploadStatus: outputResult.pdfUploadStatus, + pdfFileId: createdPdfFileId, + }; +} + +/** + * Polls Python agent for session status and emits socket events: + * - 'selenium:otp_required' when waiting_for_otp + * - 'selenium:session_update' when completed/error + */ +async function pollAgentSessionAndProcess( + sessionId: string, + socketId?: string +) { + const maxAttempts = 300; // ~5 minutes @ 1s + const delayMs = 1000; + + const job = ddmaJobs[sessionId]; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const st = await getSeleniumDdmaSessionStatus(sessionId); + const status = st?.status; + + if (status === "waiting_for_otp") { + if (socketId && io && io.sockets.sockets.get(socketId)) { + io.to(socketId).emit("selenium:otp_required", { + session_id: sessionId, + message: "OTP required. Please enter the OTP.", + }); + } + // once waiting_for_otp, we stop polling here; OTP flow continues separately + return; + } + + if (status === "completed") { + // run DB + PDF pipeline + let finalResult: any = null; + if (job && st.result) { + try { + finalResult = await handleDdmaCompletedJob( + sessionId, + job, + st.result + ); + } catch (err: any) { + finalResult = { + error: "Failed to process ddma completed job", + detail: err?.message ?? String(err), + }; + } + } + + if (socketId && io && io.sockets.sockets.get(socketId)) { + io.to(socketId).emit("selenium:session_update", { + session_id: sessionId, + status: "completed", + rawSelenium: st.result, + final: finalResult, + }); + } + delete ddmaJobs[sessionId]; + return; + } + + if (status === "error" || status === "not_found") { + if (socketId && io && io.sockets.sockets.get(socketId)) { + io.to(socketId).emit("selenium:session_update", { + session_id: sessionId, + status, + message: st?.message || "Selenium session error", + }); + } + delete ddmaJobs[sessionId]; + return; + } + } catch (err) { + // swallow transient errors and keep polling + console.warn("pollAgentSessionAndProcess error", err); + } + + await new Promise((r) => setTimeout(r, delayMs)); + } + + // fallback: timeout + if (socketId && io && io.sockets.sockets.get(socketId)) { + io.to(socketId).emit("selenium:session_update", { + session_id: sessionId, + status: "error", + message: "Polling timeout while waiting for selenium session", + }); + } +} + +/** + * POST /ddma-eligibility + * Starts DDMA eligibility Selenium job. + * Expects: + * - req.body.data: stringified JSON like your existing /eligibility-check + * - req.body.socketId: socket.io client id + */ +router.post( + "/ddma-eligibility", + async (req: Request, res: Response): Promise => { + if (!req.body.data) { + return res + .status(400) + .json({ error: "Missing Insurance Eligibility data for selenium" }); + } + + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const rawData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data; + + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + rawData.insuranceSiteKey + ); + if (!credentials) { + return res.status(404).json({ + error: + "No insurance credentials found for this provider, Kindly Update this at Settings Page.", + }); + } + + const enrichedData = { + ...rawData, + massddmaUsername: credentials.username, + massddmaPassword: credentials.password, + }; + + const socketId: string | undefined = req.body.socketId; + + const agentResp = await forwardToSeleniumDdmaEligibilityAgent( + enrichedData, + ); + + if (!agentResp || agentResp.status !== "started" || !agentResp.session_id) { + return res.status(502).json({ + error: "Selenium agent did not return a started session", + detail: agentResp, + }); + } + + const sessionId = agentResp.session_id as string; + + // Save job context + ddmaJobs[sessionId] = { + userId: req.user.id, + insuranceEligibilityData: enrichedData, + }; + + // start polling in background to notify client via socket and process job + pollAgentSessionAndProcess(sessionId, socketId).catch((e) => + console.warn("pollAgentSessionAndProcess failed", e) + ); + + // reply immediately with started status + return res.json({ status: "started", session_id: sessionId }); + } catch (err: any) { + console.error(err); + return res.status(500).json({ + error: err.message || "Failed to start ddma selenium agent", + }); + } + } +); + +/** + * POST /selenium/submit-otp + * Body: { session_id, otp, socketId? } + * Forwards OTP to Python agent and optionally notifies client socket. + */ +router.post( + "/selenium/submit-otp", + async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res + .status(400) + .json({ error: "session_id and otp are required" }); + } + + try { + const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp); + + // notify socket that OTP was accepted (if socketId present) + try { + const { io } = require("../socket"); + if (socketId && io && io.sockets.sockets.get(socketId)) { + io.to(socketId).emit("selenium:otp_submitted", { + session_id: sessionId, + result: r, + }); + } + } catch (emitErr) { + console.warn("Failed to emit selenium:otp_submitted", emitErr); + } + + return res.json(r); + } catch (err: any) { + console.error("Failed to forward OTP:", err?.response?.data || err?.message || err); + return res.status(500).json({ + error: "Failed to forward otp to selenium agent", + detail: err?.message || err, + }); + } + } +); + +export default router; diff --git a/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts new file mode 100644 index 0000000..1fecadd --- /dev/null +++ b/apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts @@ -0,0 +1,72 @@ +import axios from "axios"; +import dotenv from "dotenv"; +dotenv.config(); + +export interface SeleniumPayload { + data: any; + url?: string; +} + +const SELENIUM_AGENT_BASE = + process.env.SELENIUM_AGENT_BASE_URL; + +export async function forwardToSeleniumDdmaEligibilityAgent( + insuranceEligibilityData: any, +): Promise { + const payload: SeleniumPayload = { + data: insuranceEligibilityData, + }; + + const url = `${SELENIUM_AGENT_BASE}/ddma-eligibility`; + console.log(url) + const result = await axios.post( + `${SELENIUM_AGENT_BASE}/ddma-eligibility`, + payload, + { timeout: 5 * 60 * 1000 } + ); + + if (!result || !result.data) { + throw new Error("Empty response from selenium agent"); + } + + if (result.data.status === "error") { + const errorMsg = + typeof result.data.message === "string" + ? result.data.message + : result.data.message?.msg || "Selenium agent error"; + throw new Error(errorMsg); + } + + return result.data; // { status: "started", session_id } +} + +export async function forwardOtpToSeleniumDdmaAgent( + sessionId: string, + otp: string +): Promise { + const result = await axios.post(`${SELENIUM_AGENT_BASE}/submit-otp`, { + session_id: sessionId, + otp, + }); + + if (!result || !result.data) throw new Error("Empty OTP response"); + if (result.data.status === "error") { + const message = + typeof result.data.message === "string" + ? result.data.message + : JSON.stringify(result.data); + throw new Error(message); + } + + return result.data; +} + +export async function getSeleniumDdmaSessionStatus( + sessionId: string +): Promise { + const result = await axios.get( + `${SELENIUM_AGENT_BASE}/session/${sessionId}/status` + ); + if (!result || !result.data) throw new Error("Empty session status"); + return result.data; +} diff --git a/apps/Backend/src/socket.ts b/apps/Backend/src/socket.ts new file mode 100644 index 0000000..b5e764f --- /dev/null +++ b/apps/Backend/src/socket.ts @@ -0,0 +1,53 @@ +import { Server as HttpServer } from "http"; +import { Server, Socket } from "socket.io"; + +let io: Server | null = null; + +export function initSocket(server: HttpServer) { + const NODE_ENV = ( + process.env.NODE_ENV || + process.env.ENV || + "development" + ).toLowerCase(); + + const rawFrontendUrls = + process.env.FRONTEND_URLS || process.env.FRONTEND_URL || ""; + const FRONTEND_URLS = rawFrontendUrls + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + // In dev: allow all origins + // In prod: restrict to FRONTEND_URLS if provided + const corsOrigin = + NODE_ENV !== "production" + ? true + : FRONTEND_URLS.length > 0 + ? FRONTEND_URLS + : false; // no origins allowed if none configured in prod + + io = new Server(server, { + cors: { + origin: corsOrigin, + methods: ["GET", "POST"], + credentials: true, + }, + }); + + io.on("connection", (socket: Socket) => { + console.log("πŸ”Œ Socket connected:", socket.id); + + socket.on("disconnect", () => { + console.log("πŸ”Œ Socket disconnected:", socket.id); + }); + }); + + // Optional: log low-level engine errors for debugging + io.engine.on("connection_error", (err) => { + console.error("Socket engine connection_error:", err); + }); + + return io; +} + +export { io }; diff --git a/apps/Frontend/package.json b/apps/Frontend/package.json index 7baca06..026861a 100644 --- a/apps/Frontend/package.json +++ b/apps/Frontend/package.json @@ -76,6 +76,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.7", "recharts": "^2.15.2", + "socket.io-client": "^4.8.1", "tailwind-merge": "^3.2.0", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx new file mode 100644 index 0000000..9ffb688 --- /dev/null +++ b/apps/Frontend/src/components/insurance-status/ddma-buton-modal.tsx @@ -0,0 +1,426 @@ +import { useEffect, useRef, useState } from "react"; +import { io as ioClient, Socket } from "socket.io-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CheckCircle, LoaderCircleIcon, X } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useAppDispatch } from "@/redux/hooks"; +import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice"; +import { formatLocalDate } from "@/utils/dateUtils"; +import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; + +// Use Vite env (set VITE_BACKEND_URL in your frontend .env) +const SOCKET_URL = + import.meta.env.VITE_API_BASE_URL_BACKEND || + (typeof window !== "undefined" ? window.location.origin : ""); + +// ---------- OTP Modal component ---------- +interface DdmaOtpModalProps { + open: boolean; + onClose: () => void; + onSubmit: (otp: string) => Promise | void; + isSubmitting: boolean; +} + +function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }: DdmaOtpModalProps) { + const [otp, setOtp] = useState(""); + + useEffect(() => { + if (!open) setOtp(""); + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!otp.trim()) return; + await onSubmit(otp.trim()); + }; + + return ( +
+
+
+

Enter OTP

+ +
+

+ We need the one-time password (OTP) sent by the Delta Dental MA portal + to complete this eligibility check. +

+
+
+ + setOtp(e.target.value)} + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} + +// ---------- Main DDMA Eligibility button component ---------- +interface DdmaEligibilityButtonProps { + memberId: string; + dateOfBirth: Date | null; + firstName: string; + lastName: string; + isFormIncomplete: boolean; + /** Called when backend has finished and PDF is ready */ + onPdfReady: (pdfId: number, fallbackFilename: string | null) => void; +} + +export function DdmaEligibilityButton({ + memberId, + dateOfBirth, + firstName, + lastName, + isFormIncomplete, + onPdfReady, +}: DdmaEligibilityButtonProps) { + const { toast } = useToast(); + const dispatch = useAppDispatch(); + + const socketRef = useRef(null); + const connectingRef = useRef | null>(null); + + const [sessionId, setSessionId] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isSubmittingOtp, setIsSubmittingOtp] = useState(false); + + // Clean up socket on unmount + useEffect(() => { + return () => { + if (socketRef.current) { + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + socketRef.current = null; + } + connectingRef.current = null; + }; + }, []); + + // Lazy socket setup: called only when we actually need it (first click) + const ensureSocketConnected = async () => { + // If already connected, nothing to do + if (socketRef.current && socketRef.current.connected) { + return; + } + + // If a connection is in progress, reuse that promise + if (connectingRef.current) { + return connectingRef.current; + } + + const promise = new Promise((resolve, reject) => { + const socket = ioClient(SOCKET_URL, { + withCredentials: true, + }); + + socketRef.current = socket; + + socket.on("connect", () => { + console.log("DDMA socket connected:", socket.id); + resolve(); + }); + + socket.on("connect_error", (err) => { + console.error("DDMA socket connect_error:", err); + reject(err); + }); + + socket.on("disconnect", () => { + console.log("DDMA socket disconnected"); + }); + + // OTP required + socket.on("selenium:otp_required", (payload: any) => { + if (!payload?.session_id) return; + setSessionId(payload.session_id); + setOtpModalOpen(true); + dispatch( + setTaskStatus({ + status: "pending", + message: + "OTP required for DDMA eligibility. Please enter the OTP.", + }) + ); + }); + + // OTP submitted (optional UX) + socket.on("selenium:otp_submitted", (payload: any) => { + if (!payload?.session_id || payload.session_id !== sessionId) return; + dispatch( + setTaskStatus({ + status: "pending", + message: "OTP submitted. Finishing DDMA eligibility check...", + }) + ); + }); + + // Session update + socket.on("selenium:session_update", (payload: any) => { + const { session_id, status, final } = payload || {}; + if (!session_id || session_id !== sessionId) return; + + if (status === "completed") { + dispatch( + setTaskStatus({ + status: "success", + message: + "DDMA eligibility updated and PDF attached to patient documents.", + }) + ); + toast({ + title: "DDMA eligibility complete", + description: + "Patient status was updated and the eligibility PDF was saved.", + variant: "default", + }); + + const pdfId = final?.pdfFileId; + if (pdfId) { + const filename = + final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`; + onPdfReady(Number(pdfId), filename); + } + + setSessionId(null); + setOtpModalOpen(false); + } else if (status === "error") { + const msg = + payload?.message || + final?.error || + "DDMA eligibility session failed."; + dispatch( + setTaskStatus({ + status: "error", + message: msg, + }) + ); + toast({ + title: "DDMA selenium error", + description: msg, + variant: "destructive", + }); + setSessionId(null); + setOtpModalOpen(false); + } + + queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); + }); + }); + + connectingRef.current = promise; + + try { + await promise; + } finally { + // Once resolved or rejected, allow future attempts if needed + connectingRef.current = null; + } + }; + + const startDdmaEligibility = async () => { + if (!memberId || !dateOfBirth) { + toast({ + title: "Missing fields", + description: "Member ID and Date of Birth are required.", + variant: "destructive", + }); + return; + } + + const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; + + const payload = { + memberId, + dateOfBirth: formattedDob, + firstName, + lastName, + insuranceSiteKey: "DDMA", // make sure this matches backend credential key + }; + + try { + setIsStarting(true); + + // 1) Ensure socket is connected (lazy) + dispatch( + setTaskStatus({ + status: "pending", + message: "Opening realtime channel for DDMA eligibility...", + }) + ); + await ensureSocketConnected(); + + const socket = socketRef.current; + if (!socket || !socket.connected) { + throw new Error("Socket connection failed"); + } + + const socketId = socket.id; + + // 2) Start the selenium job via backend + dispatch( + setTaskStatus({ + status: "pending", + message: "Starting DDMA eligibility check via selenium...", + }) + ); + + const response = await apiRequest( + "POST", + "/api/insurance-status-ddma/ddma-eligibility", + { + data: JSON.stringify(payload), + socketId, + } + ); + + const result = await response.json(); + if (!response.ok || result.error) { + throw new Error(result.error || "DDMA selenium start failed"); + } + + if (result.status === "started" && result.session_id) { + setSessionId(result.session_id as string); + dispatch( + setTaskStatus({ + status: "pending", + message: + "DDMA eligibility job started. Waiting for OTP or final result...", + }) + ); + } else { + // fallback if backend returns immediate result + dispatch( + setTaskStatus({ + status: "success", + message: "DDMA eligibility completed.", + }) + ); + } + } catch (err: any) { + console.error("startDdmaEligibility error:", err); + dispatch( + setTaskStatus({ + status: "error", + message: err?.message || "Failed to start DDMA eligibility", + }) + ); + toast({ + title: "DDMA selenium error", + description: err?.message || "Failed to start DDMA eligibility", + variant: "destructive", + }); + } finally { + setIsStarting(false); + } + }; + + const handleSubmitOtp = async (otp: string) => { + if (!sessionId || !socketRef.current || !socketRef.current.connected) { + toast({ + title: "Session not ready", + description: + "Could not submit OTP because the DDMA session or socket is not ready.", + variant: "destructive", + }); + return; + } + + try { + setIsSubmittingOtp(true); + const resp = await apiRequest( + "POST", + "/api/insurance-status-ddma/selenium/submit-otp", + { + session_id: sessionId, + otp, + socketId: socketRef.current.id, + } + ); + const data = await resp.json(); + if (!resp.ok || data.error) { + throw new Error(data.error || "Failed to submit OTP"); + } + + // from here we rely on websocket events (otp_submitted + session_update) + setOtpModalOpen(false); + } catch (err: any) { + console.error("handleSubmitOtp error:", err); + toast({ + title: "Failed to submit OTP", + description: err?.message || "Error forwarding OTP to selenium agent", + variant: "destructive", + }); + } finally { + setIsSubmittingOtp(false); + } + }; + + return ( + <> + + + setOtpModalOpen(false)} + onSubmit={handleSubmitOtp} + isSubmitting={isSubmittingOtp} + /> + + ); +} diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 8ab44ff..b24f19f 100644 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -27,6 +27,7 @@ import { DateInput } from "@/components/ui/dateInput"; import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal"; import { useLocation } from "wouter"; +import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal"; export default function InsuranceStatusPage() { const { user } = useAuth(); @@ -574,19 +575,25 @@ export default function InsuranceStatusPage() { {/* TEMP PROVIDER BUTTONS */}

- Other provider checks (not working) + Other provider checks

{/* Row 1 */}
- + { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_ddma_${memberId}.pdf` + ); + setPreviewOpen(true); + }} + />
+ + {/* ORTH GROUP */} +
+
Orth
+ +
+ {[ + "orthPerioVisitDirect", + "orthPreExamDirect", + "orthRetentionDirect", + ].map((comboId) => { + const b = PROCEDURE_COMBOS[comboId]; + if (!b) return null; + + const tooltipText = b.codes.join(", "); + + return ( + + + + + + +
+ {tooltipText} +
+
+
+ ); + })} +
+
diff --git a/apps/Frontend/src/utils/procedureCombos.ts b/apps/Frontend/src/utils/procedureCombos.ts index cfd2b8c..3561252 100644 --- a/apps/Frontend/src/utils/procedureCombos.ts +++ b/apps/Frontend/src/utils/procedureCombos.ts @@ -220,6 +220,28 @@ export const PROCEDURE_COMBOS: Record< label: "Baby Teeth EXT", codes: ["D7111"], }, + + // Orthodontics + orthPerioVisitDirect: { + id: "orthPerioVisitDirect", + label: "Perio Orth Visit ", + codes: ["D8670"], + }, + orthPreExamDirect: { + id: "orthPreExamDirect", + label: "Pre-Orth Exam", + codes: ["D8660"], + }, + orthPA: { + id: "orthPA", + label: "Orth PA", + codes: ["D8080", "D8670", "D8660"], + }, + orthRetentionDirect: { + id: "orthRetentionDirect", + label: "Orth Retention", + codes: ["D8680"], + }, // add more… }; @@ -263,4 +285,5 @@ export const COMBO_CATEGORIES: Record< "surgicalExtraction", "babyTeethExtraction", ], + Orthodontics: ["orthPA"], }; From 3ad5bd633d905c0281fb4e53342acf93ee04a9da Mon Sep 17 00:00:00 2001 From: Potenz Date: Thu, 18 Dec 2025 00:38:09 +0530 Subject: [PATCH 22/28] feat(patient-connection-page demo added) --- apps/Frontend/src/App.tsx | 2 + .../src/components/layout/sidebar.tsx | 6 + .../patient-connection/message-thread.tsx | 251 ++++++++++++ .../patient-connection/sms-template-diaog.tsx | 226 +++++++++++ .../src/pages/patient-connection-page.tsx | 380 ++++++++++++++++++ packages/db/prisma/schema.prisma | 87 +++- packages/db/types/index.ts | 3 +- packages/db/types/patientConnection-types.ts | 42 ++ packages/db/usedSchemas/index.ts | 3 +- 9 files changed, 977 insertions(+), 23 deletions(-) create mode 100644 apps/Frontend/src/components/patient-connection/message-thread.tsx create mode 100644 apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx create mode 100644 apps/Frontend/src/pages/patient-connection-page.tsx create mode 100644 packages/db/types/patientConnection-types.ts diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index 1d1a49f..bea2914 100644 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -12,6 +12,7 @@ import Dashboard from "./pages/dashboard"; import LoadingScreen from "./components/ui/LoadingScreen"; const AuthPage = lazy(() => import("./pages/auth-page")); +const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page")); const AppointmentsPage = lazy(() => import("./pages/appointments-page")); const PatientsPage = lazy(() => import("./pages/patients-page")); const SettingsPage = lazy(() => import("./pages/settings-page")); @@ -34,6 +35,7 @@ function Router() { } /> } /> + } /> } diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index c526526..9f5b673 100644 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { Database, FileText, Cloud, + Phone, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMemo } from "react"; @@ -27,6 +28,11 @@ export function Sidebar() { path: "/dashboard", icon: , }, + { + name: "Patient Connection", + path: "/patient-connection", + icon: , + }, { name: "Appointments", path: "/appointments", diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx new file mode 100644 index 0000000..3d48ba4 --- /dev/null +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -0,0 +1,251 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { Send, ArrowLeft } from "lucide-react"; +import type { Patient, Communication } from "@repo/db/types"; +import { format, isToday, isYesterday, parseISO } from "date-fns"; + +interface MessageThreadProps { + patient: Patient; + onBack?: () => void; +} + +export function MessageThread({ patient, onBack }: MessageThreadProps) { + const { toast } = useToast(); + const [messageText, setMessageText] = useState(""); + const messagesEndRef = useRef(null); + + const { data: communications = [], isLoading } = useQuery({ + queryKey: ["/api/patients", patient.id, "communications"], + queryFn: async () => { + const res = await fetch(`/api/patients/${patient.id}/communications`, { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to fetch communications"); + return res.json(); + }, + refetchInterval: 5000, // Refresh every 5 seconds to get new messages + }); + + const sendMessageMutation = useMutation({ + mutationFn: async (message: string) => { + return apiRequest("POST", "/api/twilio/send-sms", { + to: patient.phone, + message: message, + patientId: patient.id, + }); + }, + onSuccess: () => { + setMessageText(""); + queryClient.invalidateQueries({ + queryKey: ["/api/patients", patient.id, "communications"], + }); + toast({ + title: "Message sent", + description: "Your message has been sent successfully.", + }); + }, + onError: (error: any) => { + toast({ + title: "Failed to send message", + description: + error.message || "Unable to send message. Please try again.", + variant: "destructive", + }); + }, + }); + + const handleSendMessage = () => { + if (!messageText.trim()) return; + sendMessageMutation.mutate(messageText); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [communications]); + + const formatMessageDate = (dateValue: string | Date) => { + const date = + typeof dateValue === "string" ? parseISO(dateValue) : dateValue; + if (isToday(date)) { + return format(date, "h:mm a"); + } else if (isYesterday(date)) { + return `Yesterday ${format(date, "h:mm a")}`; + } else { + return format(date, "MMM d, h:mm a"); + } + }; + + const getDateDivider = (dateValue: string | Date) => { + const messageDate = + typeof dateValue === "string" ? parseISO(dateValue) : dateValue; + if (isToday(messageDate)) { + return "Today"; + } else if (isYesterday(messageDate)) { + return "Yesterday"; + } else { + return format(messageDate, "MMMM d, yyyy"); + } + }; + + const groupedMessages: { date: string; messages: Communication[] }[] = []; + communications.forEach((comm) => { + if (!comm.createdAt) return; + const messageDate = + typeof comm.createdAt === "string" + ? parseISO(comm.createdAt) + : comm.createdAt; + const dateKey = format(messageDate, "yyyy-MM-dd"); + const existingGroup = groupedMessages.find((g) => g.date === dateKey); + if (existingGroup) { + existingGroup.messages.push(comm); + } else { + groupedMessages.push({ date: dateKey, messages: [comm] }); + } + }); + + return ( +
+ {/* Header */} +
+
+ {onBack && ( + + )} +
+
+ {patient.firstName[0]} + {patient.lastName[0]} +
+
+

+ {patient.firstName} {patient.lastName} +

+

{patient.phone}

+
+
+
+
+ + {/* Messages */} +
+ {isLoading ? ( +
+

Loading messages...

+
+ ) : communications.length === 0 ? ( +
+

+ No messages yet. Start the conversation! +

+
+ ) : ( + <> + {groupedMessages.map((group) => ( +
+ {/* Date Divider */} +
+
+ {getDateDivider(group.messages[0]?.createdAt!)} +
+
+ + {/* Messages for this date */} + {group.messages.map((comm) => ( +
+
+ {comm.direction === "inbound" && ( +
+
+ {patient.firstName[0]} + {patient.lastName[0]} +
+
+
+

+ {comm.body} +

+
+

+ {comm.createdAt && + formatMessageDate(comm.createdAt)} +

+
+
+ )} + + {comm.direction === "outbound" && ( +
+
+

+ {comm.body} +

+
+

+ {comm.createdAt && + formatMessageDate(comm.createdAt)} +

+
+ )} +
+
+ ))} +
+ ))} +
+ + )} +
+ + {/* Input Area */} +
+
+ setMessageText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type your message..." + className="flex-1 rounded-full" + disabled={sendMessageMutation.isPending} + data-testid="input-message" + /> + +
+
+
+ ); +} diff --git a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx new file mode 100644 index 0000000..3f74254 --- /dev/null +++ b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { MessageSquare, Send, Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import type { Patient } from "@repo/db/types"; + +interface SmsTemplateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + patient: Patient | null; +} + +const MESSAGE_TEMPLATES = { + appointment_reminder: { + name: "Appointment Reminder", + template: (firstName: string) => + `Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`, + }, + appointment_confirmation: { + name: "Appointment Confirmation", + template: (firstName: string) => + `Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`, + }, + follow_up: { + name: "Follow-Up", + template: (firstName: string) => + `Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`, + }, + payment_reminder: { + name: "Payment Reminder", + template: (firstName: string) => + `Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`, + }, + general: { + name: "General Message", + template: (firstName: string) => + `Hi ${firstName}, this is your dental office. `, + }, + custom: { + name: "Custom Message", + template: () => "", + }, +}; + +export function SmsTemplateDialog({ + open, + onOpenChange, + patient, +}: SmsTemplateDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState< + keyof typeof MESSAGE_TEMPLATES + >("appointment_reminder"); + const [customMessage, setCustomMessage] = useState(""); + const { toast } = useToast(); + + const sendSmsMutation = useMutation({ + mutationFn: async ({ + to, + message, + patientId, + }: { + to: string; + message: string; + patientId: number; + }) => { + return apiRequest("POST", "/api/twilio/send-sms", { + to, + message, + patientId, + }); + }, + onSuccess: () => { + toast({ + title: "SMS Sent Successfully", + description: `Message sent to ${patient?.firstName} ${patient?.lastName}`, + }); + onOpenChange(false); + // Reset state + setSelectedTemplate("appointment_reminder"); + setCustomMessage(""); + }, + onError: (error: any) => { + toast({ + title: "Failed to Send SMS", + description: + error.message || + "Please check your Twilio configuration and try again.", + variant: "destructive", + }); + }, + }); + + const getMessage = () => { + if (!patient) return ""; + + if (selectedTemplate === "custom") { + return customMessage; + } + + return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName); + }; + + const handleSend = () => { + if (!patient || !patient.phone) return; + + const message = getMessage(); + if (!message.trim()) return; + + sendSmsMutation.mutate({ + to: patient.phone, + message: message, + patientId: Number(patient.id), + }); + }; + + const handleTemplateChange = (value: string) => { + const templateKey = value as keyof typeof MESSAGE_TEMPLATES; + setSelectedTemplate(templateKey); + + // Pre-fill custom message if not custom template + if (templateKey !== "custom" && patient) { + setCustomMessage( + MESSAGE_TEMPLATES[templateKey].template(patient.firstName) + ); + } + }; + + return ( + + + + + + Send SMS to {patient?.firstName} {patient?.lastName} + + + Choose a message template or write a custom message + + + +
+
+ + +
+ +
+ +