diff --git a/src/routes/billing.js b/src/routes/billing.ts similarity index 56% rename from src/routes/billing.js rename to src/routes/billing.ts index e480b0e..afc3f05 100644 --- a/src/routes/billing.js +++ b/src/routes/billing.ts @@ -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 = { 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 = { '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 = {}; + 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 = {}; - // 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; diff --git a/src/routes/nextcloud.js b/src/routes/nextcloud.ts similarity index 50% rename from src/routes/nextcloud.js rename to src/routes/nextcloud.ts index fc171fe..e3a6fed 100644 --- a/src/routes/nextcloud.js +++ b/src/routes/nextcloud.ts @@ -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;