refactor(ts): day 3 batch 5 — billing, nextcloud (+ CPT/ICD10 types)
This commit is contained in:
parent
4434df89b2
commit
faaff64183
2 changed files with 145 additions and 191 deletions
|
|
@ -3,82 +3,80 @@
|
|||
// Uses NLM Clinical Tables API for ICD-10 lookup (free, no auth)
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
import express = require('express');
|
||||
import type { Request, Response } from 'express';
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// ── CPT E/M Reference Tables ──────────────────────────────────────
|
||||
const router = express.Router();
|
||||
|
||||
var CPT_EM = {
|
||||
interface CptCode { code: string; level?: number; desc: string; time?: string; note?: string }
|
||||
|
||||
const CPT_EM: Record<string, CptCode[]> = {
|
||||
outpatient_new: [
|
||||
{ code: '99202', level: 2, desc: 'New patient, straightforward MDM', time: '15-29 min' },
|
||||
{ code: '99203', level: 3, desc: 'New patient, low complexity MDM', time: '30-44 min' },
|
||||
{ code: '99204', level: 4, desc: 'New patient, moderate complexity MDM', time: '45-59 min' },
|
||||
{ code: '99205', level: 5, desc: 'New patient, high complexity MDM', time: '60-74 min' }
|
||||
{ code: '99205', level: 5, desc: 'New patient, high complexity MDM', time: '60-74 min' },
|
||||
],
|
||||
outpatient_established: [
|
||||
{ code: '99211', level: 1, desc: 'Established patient, may not require physician', time: '—' },
|
||||
{ code: '99212', level: 2, desc: 'Established patient, straightforward MDM', time: '10-19 min' },
|
||||
{ code: '99213', level: 3, desc: 'Established patient, low complexity MDM', time: '20-29 min' },
|
||||
{ code: '99214', level: 4, desc: 'Established patient, moderate complexity MDM', time: '30-39 min' },
|
||||
{ code: '99215', level: 5, desc: 'Established patient, high complexity MDM', time: '40-54 min' }
|
||||
{ code: '99215', level: 5, desc: 'Established patient, high complexity MDM', time: '40-54 min' },
|
||||
],
|
||||
wellvisit_new: [
|
||||
{ code: '99381', desc: 'Preventive visit, new patient, infant (< 1 year)' },
|
||||
{ code: '99382', desc: 'Preventive visit, new patient, early childhood (1-4 years)' },
|
||||
{ code: '99383', desc: 'Preventive visit, new patient, late childhood (5-11 years)' },
|
||||
{ code: '99384', desc: 'Preventive visit, new patient, adolescent (12-17 years)' },
|
||||
{ code: '99385', desc: 'Preventive visit, new patient, 18-39 years' }
|
||||
{ code: '99385', desc: 'Preventive visit, new patient, 18-39 years' },
|
||||
],
|
||||
wellvisit_established: [
|
||||
{ code: '99391', desc: 'Preventive visit, established patient, infant (< 1 year)' },
|
||||
{ code: '99392', desc: 'Preventive visit, established patient, early childhood (1-4 years)' },
|
||||
{ code: '99393', desc: 'Preventive visit, established patient, late childhood (5-11 years)' },
|
||||
{ code: '99394', desc: 'Preventive visit, established patient, adolescent (12-17 years)' },
|
||||
{ code: '99395', desc: 'Preventive visit, established patient, 18-39 years' }
|
||||
{ code: '99395', desc: 'Preventive visit, established patient, 18-39 years' },
|
||||
],
|
||||
ed: [
|
||||
{ code: '99281', level: 1, desc: 'ED visit, straightforward MDM' },
|
||||
{ code: '99282', level: 2, desc: 'ED visit, low complexity MDM' },
|
||||
{ code: '99283', level: 3, desc: 'ED visit, moderate complexity MDM' },
|
||||
{ code: '99284', level: 4, desc: 'ED visit, moderate-high complexity MDM' },
|
||||
{ code: '99285', level: 5, desc: 'ED visit, high complexity MDM' }
|
||||
{ code: '99285', level: 5, desc: 'ED visit, high complexity MDM' },
|
||||
],
|
||||
inpatient_admit: [
|
||||
{ code: '99221', level: 1, desc: 'Initial hospital care, straightforward/low MDM' },
|
||||
{ code: '99222', level: 2, desc: 'Initial hospital care, moderate MDM' },
|
||||
{ code: '99223', level: 3, desc: 'Initial hospital care, high MDM' }
|
||||
{ code: '99223', level: 3, desc: 'Initial hospital care, high MDM' },
|
||||
],
|
||||
inpatient_subsequent: [
|
||||
{ code: '99231', level: 1, desc: 'Subsequent hospital care, straightforward/low MDM' },
|
||||
{ code: '99232', level: 2, desc: 'Subsequent hospital care, moderate MDM' },
|
||||
{ code: '99233', level: 3, desc: 'Subsequent hospital care, high MDM' }
|
||||
{ code: '99233', level: 3, desc: 'Subsequent hospital care, high MDM' },
|
||||
],
|
||||
inpatient_discharge: [
|
||||
{ code: '99238', desc: 'Hospital discharge, 30 min or less' },
|
||||
{ code: '99239', desc: 'Hospital discharge, more than 30 min' }
|
||||
]
|
||||
{ code: '99239', desc: 'Hospital discharge, more than 30 min' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── ROS Systems for counting ──────────────────────────────────────
|
||||
var ROS_SYSTEMS = [
|
||||
const ROS_SYSTEMS: string[] = [
|
||||
'constitutional', 'eyes', 'ent', 'cardiovascular', 'respiratory',
|
||||
'gastrointestinal', 'genitourinary', 'musculoskeletal', 'integumentary',
|
||||
'neurological', 'psychiatric', 'endocrine', 'hematologic', 'allergic'
|
||||
'neurological', 'psychiatric', 'endocrine', 'hematologic', 'allergic',
|
||||
];
|
||||
|
||||
var PE_SYSTEMS = [
|
||||
const PE_SYSTEMS: string[] = [
|
||||
'general', 'heent', 'eyes', 'ears', 'nose', 'throat', 'neck',
|
||||
'cardiovascular', 'respiratory', 'chest', 'lungs', 'abdomen', 'abdominal',
|
||||
'genitourinary', 'musculoskeletal', 'skin', 'integumentary',
|
||||
'neurologic', 'neurological', 'psychiatric', 'lymphatic'
|
||||
'neurologic', 'neurological', 'psychiatric', 'lymphatic',
|
||||
];
|
||||
|
||||
// ── Extract diagnoses from note text ─────────────────────────────
|
||||
|
||||
// Common pediatric diagnoses — avoids NLM lookup for known terms
|
||||
var COMMON_ICD10 = {
|
||||
const COMMON_ICD10: Record<string, { code: string; name: string }> = {
|
||||
'fever': { code: 'R50.9', name: 'Fever, unspecified' },
|
||||
'cough': { code: 'R05.9', name: 'Cough, unspecified' },
|
||||
'vomiting': { code: 'R11.10', name: 'Vomiting, unspecified' },
|
||||
|
|
@ -122,255 +120,210 @@ var COMMON_ICD10 = {
|
|||
'newborn': { code: 'Z00.110', name: 'Health examination for newborn under 8 days old' },
|
||||
'jaundice': { code: 'P59.9', name: 'Neonatal jaundice, unspecified' },
|
||||
'neonatal jaundice': { code: 'P59.9', name: 'Neonatal jaundice, unspecified' },
|
||||
'hyperbilirubinemia': { code: 'P59.9', name: 'Neonatal jaundice, unspecified' }
|
||||
'hyperbilirubinemia': { code: 'P59.9', name: 'Neonatal jaundice, unspecified' },
|
||||
};
|
||||
|
||||
function extractDiagnoses(noteText) {
|
||||
var diagnoses = [];
|
||||
interface Diagnosis { term: string; type: string }
|
||||
|
||||
// Look for Assessment section
|
||||
var assessmentMatch = noteText.match(/(?:assessment|diagnos[ie]s|impression|a\/p|assessment\s*(?:and|&)\s*plan)[:\s]*\n?([\s\S]*?)(?:\n\s*(?:plan|disposition|follow.up|return|recommendations)[:\s]|\n\s*\n\s*\n|$)/i);
|
||||
var assessmentText = assessmentMatch ? assessmentMatch[1] : noteText;
|
||||
function extractDiagnoses(noteText: string): Diagnosis[] {
|
||||
const diagnoses: Diagnosis[] = [];
|
||||
|
||||
// Extract ICD-10 codes already in the text (e.g., "J06.9" or "(J06.9)")
|
||||
var icdPattern = /\b([A-TV-Z]\d{2}(?:\.\d{1,4})?)\b/g;
|
||||
var icdMatch;
|
||||
const assessmentMatch = noteText.match(/(?:assessment|diagnos[ie]s|impression|a\/p|assessment\s*(?:and|&)\s*plan)[:\s]*\n?([\s\S]*?)(?:\n\s*(?:plan|disposition|follow.up|return|recommendations)[:\s]|\n\s*\n\s*\n|$)/i);
|
||||
const assessmentText = assessmentMatch ? assessmentMatch[1] : noteText;
|
||||
|
||||
const icdPattern = /\b([A-TV-Z]\d{2}(?:\.\d{1,4})?)\b/g;
|
||||
let icdMatch: RegExpExecArray | null;
|
||||
while ((icdMatch = icdPattern.exec(noteText)) !== null) {
|
||||
diagnoses.push({ term: icdMatch[1], type: 'icd_extracted' });
|
||||
}
|
||||
|
||||
// Extract numbered diagnoses from assessment
|
||||
var numberedLines = assessmentText.match(/^\s*\d+[\.\)]\s*(.+)$/gm);
|
||||
const numberedLines = assessmentText.match(/^\s*\d+[\.\)]\s*(.+)$/gm);
|
||||
if (numberedLines) {
|
||||
numberedLines.forEach(function(line) {
|
||||
var term = line.replace(/^\s*\d+[\.\)]\s*/, '').replace(/\s*[\(\[].*$/, '').trim();
|
||||
if (term.length > 3 && term.length < 100) {
|
||||
diagnoses.push({ term: term, type: 'numbered' });
|
||||
}
|
||||
numberedLines.forEach((line) => {
|
||||
const term = line.replace(/^\s*\d+[\.\)]\s*/, '').replace(/\s*[\(\[].*$/, '').trim();
|
||||
if (term.length > 3 && term.length < 100) diagnoses.push({ term, type: 'numbered' });
|
||||
});
|
||||
}
|
||||
|
||||
// Extract dash/bullet diagnoses
|
||||
var bulletLines = assessmentText.match(/^\s*[-•]\s*(.+)$/gm);
|
||||
const bulletLines = assessmentText.match(/^\s*[-•]\s*(.+)$/gm);
|
||||
if (bulletLines) {
|
||||
bulletLines.forEach(function(line) {
|
||||
var term = line.replace(/^\s*[-•]\s*/, '').replace(/\s*[\(\[].*$/, '').trim();
|
||||
if (term.length > 3 && term.length < 100) {
|
||||
diagnoses.push({ term: term, type: 'bullet' });
|
||||
}
|
||||
bulletLines.forEach((line) => {
|
||||
const term = line.replace(/^\s*[-•]\s*/, '').replace(/\s*[\(\[].*$/, '').trim();
|
||||
if (term.length > 3 && term.length < 100) diagnoses.push({ term, type: 'bullet' });
|
||||
});
|
||||
}
|
||||
|
||||
// If no structured diagnoses found, try to extract from inline text
|
||||
if (diagnoses.length === 0) {
|
||||
var commonPatterns = [
|
||||
/(?:diagnosed? with|assessment of|consistent with|suggestive of|likely|probable)\s+([^,\.\n]{3,60})/gi
|
||||
const commonPatterns = [
|
||||
/(?:diagnosed? with|assessment of|consistent with|suggestive of|likely|probable)\s+([^,\.\n]{3,60})/gi,
|
||||
];
|
||||
commonPatterns.forEach(function(pattern) {
|
||||
var m;
|
||||
commonPatterns.forEach((pattern) => {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(assessmentText)) !== null) {
|
||||
diagnoses.push({ term: m[1].trim(), type: 'inline' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
var seen = {};
|
||||
return diagnoses.filter(function(d) {
|
||||
var key = d.term.toLowerCase();
|
||||
const seen: Record<string, boolean> = {};
|
||||
return diagnoses.filter((d) => {
|
||||
const key = d.term.toLowerCase();
|
||||
if (seen[key]) return false;
|
||||
seen[key] = true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Count ROS and PE systems documented ──────────────────────────
|
||||
function countDocumentedSystems(noteText: string): { rosCount: number; peCount: number } {
|
||||
const lower = noteText.toLowerCase();
|
||||
let rosCount = 0;
|
||||
let peCount = 0;
|
||||
|
||||
function countDocumentedSystems(noteText) {
|
||||
var lower = noteText.toLowerCase();
|
||||
var rosCount = 0;
|
||||
var peCount = 0;
|
||||
|
||||
ROS_SYSTEMS.forEach(function(sys) {
|
||||
if (lower.indexOf(sys) !== -1) rosCount++;
|
||||
});
|
||||
// Also check common abbreviations
|
||||
ROS_SYSTEMS.forEach((sys) => { if (lower.indexOf(sys) !== -1) rosCount++; });
|
||||
if (/\bENT\b/.test(noteText)) rosCount++;
|
||||
if (/\bGI\b/.test(noteText)) rosCount++;
|
||||
if (/\bGU\b/.test(noteText)) rosCount++;
|
||||
if (/\bMSK\b/.test(noteText)) rosCount++;
|
||||
|
||||
PE_SYSTEMS.forEach(function(sys) {
|
||||
if (lower.indexOf(sys) !== -1) peCount++;
|
||||
});
|
||||
PE_SYSTEMS.forEach((sys) => { if (lower.indexOf(sys) !== -1) peCount++; });
|
||||
|
||||
return { rosCount: Math.min(rosCount, 14), peCount: Math.min(peCount, 12) };
|
||||
}
|
||||
|
||||
// ── Estimate E/M complexity level ────────────────────────────────
|
||||
|
||||
function estimateEMLevel(noteText, diagnosisCount) {
|
||||
var systems = countDocumentedSystems(noteText);
|
||||
var noteLength = noteText.length;
|
||||
|
||||
// MDM complexity estimation (simplified 2021 E/M guidelines)
|
||||
// Based on: number of diagnoses, data reviewed, risk
|
||||
var mdmLevel = 2; // default straightforward
|
||||
function estimateEMLevel(noteText: string, diagnosisCount: number) {
|
||||
const systems = countDocumentedSystems(noteText);
|
||||
const noteLength = noteText.length;
|
||||
|
||||
let mdmLevel = 2;
|
||||
if (diagnosisCount >= 4 || noteLength > 3000) mdmLevel = 4;
|
||||
else if (diagnosisCount >= 2 || noteLength > 1500) mdmLevel = 3;
|
||||
|
||||
// Check for high-risk indicators
|
||||
var highRisk = /(?:admit|hospitali[sz]|intubat|seizure|sepsis|meningitis|fracture|surgery|ICU|PICU|NICU|emergent|critical|unstable)/i.test(noteText);
|
||||
const highRisk = /(?:admit|hospitali[sz]|intubat|seizure|sepsis|meningitis|fracture|surgery|ICU|PICU|NICU|emergent|critical|unstable)/i.test(noteText);
|
||||
if (highRisk) mdmLevel = Math.max(mdmLevel, 4);
|
||||
|
||||
var moderateRisk = /(?:dehydrat|IV fluid|antibiotic|x-ray|CT scan|MRI|ultrasound|lab|CBC|CMP|urinalysis|blood culture|lumbar puncture)/i.test(noteText);
|
||||
const moderateRisk = /(?:dehydrat|IV fluid|antibiotic|x-ray|CT scan|MRI|ultrasound|lab|CBC|CMP|urinalysis|blood culture|lumbar puncture)/i.test(noteText);
|
||||
if (moderateRisk) mdmLevel = Math.max(mdmLevel, 3);
|
||||
|
||||
return {
|
||||
level: Math.min(mdmLevel, 5),
|
||||
rosCount: systems.rosCount,
|
||||
peCount: systems.peCount,
|
||||
diagnosisCount: diagnosisCount,
|
||||
complexity: mdmLevel >= 4 ? 'high' : mdmLevel >= 3 ? 'moderate' : 'low'
|
||||
diagnosisCount,
|
||||
complexity: mdmLevel >= 4 ? 'high' : mdmLevel >= 3 ? 'moderate' : 'low',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Get age category for well visit CPT ──────────────────────────
|
||||
function getWellVisitCPT(patientAge?: string): CptCode | null {
|
||||
const ageStr = (patientAge || '').toLowerCase();
|
||||
let ageYears = 0;
|
||||
const yearMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:years?|yrs?|y)\b/);
|
||||
const monthMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:months?|mos?|mo)\b/);
|
||||
const weekMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:weeks?|wks?|wk)\b/);
|
||||
const dayMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:days?|d)\b/);
|
||||
|
||||
function getWellVisitCPT(patientAge) {
|
||||
var ageStr = (patientAge || '').toLowerCase();
|
||||
// Sum all units so "4 yr 11 mo" → 4.91 years, not 4. Prior behaviour
|
||||
// used only the first matched unit, which mis-bucketed patients like
|
||||
// 59 months (should be 5-11y bracket, was billed as 1-4y).
|
||||
var ageYears = 0;
|
||||
var yearMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:years?|yrs?|y)\b/);
|
||||
var monthMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:months?|mos?|mo)\b/);
|
||||
var weekMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:weeks?|wks?|wk)\b/);
|
||||
var dayMatch = ageStr.match(/(\d+(?:\.\d+)?)\s*(?:days?|d)\b/);
|
||||
|
||||
if (yearMatch) ageYears += parseFloat(yearMatch[1]);
|
||||
if (yearMatch) ageYears += parseFloat(yearMatch[1]);
|
||||
if (monthMatch) ageYears += parseFloat(monthMatch[1]) / 12;
|
||||
if (weekMatch) ageYears += parseFloat(weekMatch[1]) / 52;
|
||||
if (dayMatch) ageYears += parseFloat(dayMatch[1]) / 365;
|
||||
if (weekMatch) ageYears += parseFloat(weekMatch[1]) / 52;
|
||||
if (dayMatch) ageYears += parseFloat(dayMatch[1]) / 365;
|
||||
|
||||
// Sanity-bound: negative or absurdly large values default to the
|
||||
// lowest-level code. 0 is legitimate (newborn well visit).
|
||||
if (isNaN(ageYears) || ageYears < 0 || ageYears > 120) ageYears = 0;
|
||||
|
||||
// Established patient codes (most common)
|
||||
if (ageYears < 1) return CPT_EM.wellvisit_established[0];
|
||||
if (ageYears < 5) return CPT_EM.wellvisit_established[1];
|
||||
if (ageYears < 1) return CPT_EM.wellvisit_established[0];
|
||||
if (ageYears < 5) return CPT_EM.wellvisit_established[1];
|
||||
if (ageYears < 12) return CPT_EM.wellvisit_established[2];
|
||||
if (ageYears < 18) return CPT_EM.wellvisit_established[3];
|
||||
return CPT_EM.wellvisit_established[4];
|
||||
}
|
||||
|
||||
// ── NLM ICD-10 lookup ────────────────────────────────────────────
|
||||
|
||||
async function lookupICD10(term) {
|
||||
async function lookupICD10(term: string): Promise<{ code: string; name: string }[]> {
|
||||
try {
|
||||
// Use maxList=1 for short/generic terms, 2 for longer specific terms
|
||||
var maxList = term.split(/\s+/).length >= 3 ? 2 : 1;
|
||||
var url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms=' + encodeURIComponent(term) + '&maxList=' + maxList;
|
||||
var resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
const maxList = term.split(/\s+/).length >= 3 ? 2 : 1;
|
||||
const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms=' + encodeURIComponent(term) + '&maxList=' + maxList;
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!resp.ok) return [];
|
||||
var data = await resp.json();
|
||||
// NLM response format: [totalCount, codeArray, extraDataArray, displayArray]
|
||||
const data = await resp.json();
|
||||
if (!data || !data[1] || !data[3]) return [];
|
||||
return data[3].map(function(item) {
|
||||
return { code: item[0], name: item[1] };
|
||||
});
|
||||
return (data[3] as any[]).map((item: any) => ({ code: item[0], name: item[1] }));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main endpoint ────────────────────────────────────────────────
|
||||
|
||||
router.post('/suggest-codes', authMiddleware, async function(req, res) {
|
||||
router.post('/suggest-codes', authMiddleware, async function (req: Request, res: Response) {
|
||||
try {
|
||||
var { noteText, noteType, patientAge, visitType } = req.body;
|
||||
const { noteText, noteType, patientAge, visitType } = req.body;
|
||||
if (!noteText) return res.status(400).json({ error: 'No note text provided' });
|
||||
// ReDoS guard — regex over note text can blow up on pathological inputs.
|
||||
if (typeof noteText !== 'string' || noteText.length > 20000) {
|
||||
return res.status(400).json({ error: 'Note too long for billing analysis (max 20,000 chars)' });
|
||||
}
|
||||
|
||||
// 1. Extract diagnoses from note text
|
||||
var diagnoses = extractDiagnoses(noteText);
|
||||
const diagnoses = extractDiagnoses(noteText);
|
||||
|
||||
// 2. Look up ICD-10 codes via NLM for each diagnosis
|
||||
var icd10Results = [];
|
||||
var seen = {};
|
||||
const icd10Results: { code: string; name: string; source: string }[] = [];
|
||||
const seen: Record<string, boolean> = {};
|
||||
|
||||
// First add any ICD-10 codes already extracted from the text
|
||||
diagnoses.filter(function(d) { return d.type === 'icd_extracted'; }).forEach(function(d) {
|
||||
diagnoses.filter((d) => d.type === 'icd_extracted').forEach((d) => {
|
||||
if (!seen[d.term]) { icd10Results.push({ code: d.term, name: '', source: 'extracted' }); seen[d.term] = true; }
|
||||
});
|
||||
|
||||
// Then look up non-ICD terms — check local common map first, then NLM
|
||||
var lookupTerms = diagnoses.filter(function(d) { return d.type !== 'icd_extracted'; }).slice(0, 8);
|
||||
for (var i = 0; i < lookupTerms.length; i++) {
|
||||
var termLower = lookupTerms[i].term.toLowerCase().trim();
|
||||
var localMatch = COMMON_ICD10[termLower];
|
||||
const lookupTerms = diagnoses.filter((d) => d.type !== 'icd_extracted').slice(0, 8);
|
||||
for (let i = 0; i < lookupTerms.length; i++) {
|
||||
const termLower = lookupTerms[i].term.toLowerCase().trim();
|
||||
const localMatch = COMMON_ICD10[termLower];
|
||||
if (localMatch && !seen[localMatch.code]) {
|
||||
icd10Results.push({ code: localMatch.code, name: localMatch.name, source: 'local' });
|
||||
seen[localMatch.code] = true;
|
||||
} else if (!localMatch) {
|
||||
var results = await lookupICD10(lookupTerms[i].term);
|
||||
results.forEach(function(r) {
|
||||
const results = await lookupICD10(lookupTerms[i].term);
|
||||
results.forEach((r) => {
|
||||
if (!seen[r.code]) { icd10Results.push({ code: r.code, name: r.name, source: 'nlm' }); seen[r.code] = true; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Suggest CPT codes based on note type and complexity
|
||||
var cptResults = [];
|
||||
var emLevel = estimateEMLevel(noteText, diagnoses.length);
|
||||
const cptResults: CptCode[] = [];
|
||||
const emLevel = estimateEMLevel(noteText, diagnoses.length);
|
||||
|
||||
if (noteType === 'wellvisit') {
|
||||
var wvCode = getWellVisitCPT(patientAge);
|
||||
const wvCode = getWellVisitCPT(patientAge);
|
||||
if (wvCode) cptResults.push(wvCode);
|
||||
// Well visits may also have a problem-oriented code if issues found
|
||||
if (diagnoses.length > 1) {
|
||||
cptResults.push({ code: '99213', desc: 'May also bill problem-oriented visit if significant issue addressed', note: 'modifier -25' });
|
||||
}
|
||||
} else if (noteType === 'hospital' || visitType === 'inpatient') {
|
||||
var isAdmission = /(?:admit|admission|initial hospital|H&P)/i.test(noteText);
|
||||
var isDischarge = /(?:discharge|d\/c summary)/i.test(noteText);
|
||||
const isAdmission = /(?:admit|admission|initial hospital|H&P)/i.test(noteText);
|
||||
const isDischarge = /(?:discharge|d\/c summary)/i.test(noteText);
|
||||
if (isDischarge) {
|
||||
cptResults.push(CPT_EM.inpatient_discharge[noteText.length > 2000 ? 1 : 0]);
|
||||
} else if (isAdmission) {
|
||||
var admitIdx = Math.min(emLevel.level - 2, 2);
|
||||
const admitIdx = Math.min(emLevel.level - 2, 2);
|
||||
if (admitIdx >= 0) cptResults.push(CPT_EM.inpatient_admit[admitIdx]);
|
||||
} else {
|
||||
var subIdx = Math.min(emLevel.level - 2, 2);
|
||||
const subIdx = Math.min(emLevel.level - 2, 2);
|
||||
if (subIdx >= 0) cptResults.push(CPT_EM.inpatient_subsequent[subIdx]);
|
||||
}
|
||||
} else if (visitType === 'ed') {
|
||||
var edIdx = Math.min(emLevel.level - 1, 4);
|
||||
const edIdx = Math.min(emLevel.level - 1, 4);
|
||||
cptResults.push(CPT_EM.ed[edIdx]);
|
||||
} else {
|
||||
// Outpatient — determine new vs established
|
||||
var isNew = /\bnew patient\b/i.test(noteText);
|
||||
var table = isNew ? CPT_EM.outpatient_new : CPT_EM.outpatient_established;
|
||||
var idx = Math.min(emLevel.level - 1, table.length - 1);
|
||||
const isNew = /\bnew patient\b/i.test(noteText);
|
||||
const table = isNew ? CPT_EM.outpatient_new : CPT_EM.outpatient_established;
|
||||
const idx = Math.min(emLevel.level - 1, table.length - 1);
|
||||
cptResults.push(table[idx]);
|
||||
}
|
||||
|
||||
logger.audit(req.user.id, 'suggest_billing_codes', 'Suggested ' + icd10Results.length + ' ICD-10 + ' + cptResults.length + ' CPT codes', req, { category: 'clinical' });
|
||||
logger.audit(req.user!.id, 'suggest_billing_codes', 'Suggested ' + icd10Results.length + ' ICD-10 + ' + cptResults.length + ' CPT codes', req, { category: 'clinical' });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
icd10: icd10Results.slice(0, 10),
|
||||
cpt: cptResults,
|
||||
emLevel: emLevel
|
||||
emLevel,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('[Billing] Error:', err.message);
|
||||
res.status(500).json({ error: 'Code suggestion failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export = router;
|
||||
|
|
@ -1,84 +1,85 @@
|
|||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var axios = require('axios');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var db = require('../db/database');
|
||||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
var { serverError } = require('../utils/errors');
|
||||
import express = require('express');
|
||||
import type { Request, Response } from 'express';
|
||||
const axios = require('axios');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const db = require('../db/database');
|
||||
const logger = require('../utils/logger');
|
||||
const cryptoUtil = require('../utils/crypto');
|
||||
const { serverError } = require('../utils/errors');
|
||||
|
||||
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/nextcloud/connect', authMiddleware, async function (req: Request, res: Response) {
|
||||
try {
|
||||
var { nextcloudUrl, username, appPassword, folder } = req.body;
|
||||
const { nextcloudUrl, username, appPassword, folder } = req.body;
|
||||
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
|
||||
|
||||
var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
|
||||
var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, '');
|
||||
const cleanUrl: string = nextcloudUrl.replace(/\/+$/, '');
|
||||
const targetFolder: string = (folder || '/PediatricScribe').replace(/\/+$/, '');
|
||||
|
||||
try {
|
||||
await axios({ method: 'PROPFIND', url: cleanUrl + '/remote.php/dav/files/' + username + '/', auth: { username: username, password: appPassword }, headers: { Depth: '0' } });
|
||||
await axios({ method: 'PROPFIND', url: cleanUrl + '/remote.php/dav/files/' + username + '/', auth: { username, password: appPassword }, headers: { Depth: '0' } });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: 'Cannot connect. Check credentials.' });
|
||||
}
|
||||
|
||||
var parts = targetFolder.split('/').filter(Boolean);
|
||||
var current = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
const parts = targetFolder.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
current += '/' + parts[i];
|
||||
try { await axios({ method: 'MKCOL', url: cleanUrl + '/remote.php/dav/files/' + username + current + '/', auth: { username: username, password: appPassword } }); } catch (e) {}
|
||||
try { await axios({ method: 'MKCOL', url: cleanUrl + '/remote.php/dav/files/' + username + current + '/', auth: { username, password: appPassword } }); } catch (e) {}
|
||||
}
|
||||
|
||||
await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
|
||||
[cleanUrl, username, cryptoUtil.encryptString(appPassword), targetFolder, req.user.id]);
|
||||
[cleanUrl, username, cryptoUtil.encryptString(appPassword), targetFolder, req.user!.id]);
|
||||
|
||||
res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' });
|
||||
logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud', req, { category: 'integration' });
|
||||
logger.audit(req.user!.id, 'nextcloud_connect', 'Connected Nextcloud', req, { category: 'integration' });
|
||||
} catch (err) { return serverError(res, 'Nextcloud connect', err, 'Could not connect to Nextcloud'); }
|
||||
});
|
||||
|
||||
router.post('/nextcloud/export', authMiddleware, async function(req, res) {
|
||||
router.post('/nextcloud/export', authMiddleware, async function (req: Request, res: Response) {
|
||||
try {
|
||||
var { content, filename, type } = req.body;
|
||||
var user = await db.get('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', [req.user.id]);
|
||||
const { content, filename, type } = req.body;
|
||||
const user = await db.get('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', [req.user!.id]);
|
||||
if (!user || !user.nextcloud_url) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
|
||||
|
||||
var baseFolder = user.nextcloud_folder || '/PediatricScribe';
|
||||
var today = new Date().toISOString().split('T')[0];
|
||||
var targetPath = baseFolder + '/' + today;
|
||||
const baseFolder = user.nextcloud_folder || '/PediatricScribe';
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const targetPath = baseFolder + '/' + today;
|
||||
|
||||
var pw;
|
||||
let pw: string;
|
||||
try { pw = cryptoUtil.decryptString(user.nextcloud_token); }
|
||||
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
|
||||
|
||||
var parts = targetPath.split('/').filter(Boolean);
|
||||
var current = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
const parts = targetPath.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
current += '/' + parts[i];
|
||||
try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: pw } }); } catch (e) {}
|
||||
}
|
||||
|
||||
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
var filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
|
||||
const time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||
const safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
const filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
|
||||
|
||||
await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: pw }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
|
||||
// Migrate legacy plaintext token to encrypted form on first successful use.
|
||||
if (!cryptoUtil.isEncrypted(user.nextcloud_token)) {
|
||||
db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?', [cryptoUtil.encryptString(pw), req.user.id]).catch(function(){});
|
||||
db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?', [cryptoUtil.encryptString(pw), req.user!.id]).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' });
|
||||
logger.audit(req.user.id, 'nextcloud_export', 'Exported note to Nextcloud', req, { category: 'export' });
|
||||
logger.audit(req.user!.id, 'nextcloud_export', 'Exported note to Nextcloud', req, { category: 'export' });
|
||||
} catch (err) { return serverError(res, 'Nextcloud export', err, 'Could not export to Nextcloud'); }
|
||||
});
|
||||
|
||||
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
|
||||
router.post('/nextcloud/disconnect', authMiddleware, async function (req: Request, res: Response) {
|
||||
try {
|
||||
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
|
||||
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user!.id]);
|
||||
res.json({ success: true });
|
||||
logger.audit(req.user.id, 'nextcloud_disconnect', 'Disconnected Nextcloud', req, { category: 'integration' });
|
||||
logger.audit(req.user!.id, 'nextcloud_disconnect', 'Disconnected Nextcloud', req, { category: 'integration' });
|
||||
} catch (err) { return serverError(res, 'Nextcloud disconnect', err, 'Could not disconnect'); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export = router;
|
||||
Loading…
Reference in a new issue