Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
5.8 KiB
JavaScript
162 lines
5.8 KiB
JavaScript
'use strict'
|
|
const crypto = require('crypto')
|
|
const cfg = require('./config')
|
|
const { log, json, erpFetch } = require('./helpers')
|
|
|
|
const ICAL_SECRET = cfg.ICAL_SECRET || cfg.INTERNAL_TOKEN || 'gigafibre-ical-2026'
|
|
|
|
function generateToken (techId) {
|
|
return crypto.createHmac('sha256', ICAL_SECRET).update(techId).digest('hex').slice(0, 16)
|
|
}
|
|
|
|
function validateToken (techId, token) {
|
|
return token === generateToken(techId)
|
|
}
|
|
|
|
function icalDate (dateStr, timeStr) {
|
|
const [y, m, d] = dateStr.split('-')
|
|
if (!timeStr) return `${y}${m}${d}`
|
|
const [hh, mm] = timeStr.split(':')
|
|
return `${y}${m}${d}T${hh}${mm || '00'}00`
|
|
}
|
|
|
|
function icalNow () {
|
|
const d = new Date()
|
|
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z/, 'Z')
|
|
}
|
|
|
|
// Escape iCal text values (fold long lines handled by client)
|
|
function esc (s) {
|
|
if (!s) return ''
|
|
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
|
|
}
|
|
|
|
function buildICal (techName, jobs, techId) {
|
|
const lines = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'PRODID:-//Gigafibre//Dispatch//FR',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:PUBLISH',
|
|
`X-WR-CALNAME:${esc(techName)} — Dispatch`,
|
|
'X-WR-TIMEZONE:America/Toronto',
|
|
// Timezone definition for EST/EDT
|
|
'BEGIN:VTIMEZONE',
|
|
'TZID:America/Toronto',
|
|
'BEGIN:DAYLIGHT',
|
|
'DTSTART:19700308T020000',
|
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU',
|
|
'TZOFFSETFROM:-0500',
|
|
'TZOFFSETTO:-0400',
|
|
'TZNAME:EDT',
|
|
'END:DAYLIGHT',
|
|
'BEGIN:STANDARD',
|
|
'DTSTART:19701101T020000',
|
|
'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU',
|
|
'TZOFFSETFROM:-0400',
|
|
'TZOFFSETTO:-0500',
|
|
'TZNAME:EST',
|
|
'END:STANDARD',
|
|
'END:VTIMEZONE',
|
|
]
|
|
|
|
for (const job of jobs) {
|
|
if (!job.scheduled_date) continue
|
|
const startTime = job.start_time || '08:00'
|
|
const dur = parseFloat(job.duration_h) || 1
|
|
const endMin = Math.round((parseFloat(startTime.split(':')[0]) + parseFloat(startTime.split(':')[1] || 0) / 60 + dur) * 60)
|
|
const endHH = String(Math.floor(endMin / 60)).padStart(2, '0')
|
|
const endMM = String(endMin % 60).padStart(2, '0')
|
|
const endTime = `${endHH}:${endMM}`
|
|
|
|
const dtStart = icalDate(job.scheduled_date, startTime)
|
|
const dtEnd = icalDate(job.scheduled_date, endTime)
|
|
const uid = `${job.name || job.ticket_id}@dispatch.gigafibre.ca`
|
|
|
|
const summary = job.subject || 'Dispatch Job'
|
|
const location = job.address || ''
|
|
const desc = [
|
|
job.customer ? `Client: ${job.customer}` : '',
|
|
job.priority ? `Priorité: ${job.priority}` : '',
|
|
`Durée: ${dur}h`,
|
|
job.status ? `Statut: ${job.status}` : '',
|
|
job.notes ? `\nNotes: ${job.notes}` : '',
|
|
].filter(Boolean).join('\\n')
|
|
|
|
// Status mapping
|
|
let icalStatus = 'CONFIRMED'
|
|
const st = (job.status || '').toLowerCase()
|
|
if (st === 'open') icalStatus = 'TENTATIVE'
|
|
if (st === 'cancelled') icalStatus = 'CANCELLED'
|
|
if (st === 'completed') icalStatus = 'CANCELLED' // don't show completed
|
|
|
|
lines.push(
|
|
'BEGIN:VEVENT',
|
|
`UID:${uid}`,
|
|
`DTSTAMP:${icalNow()}`,
|
|
`DTSTART;TZID=America/Toronto:${dtStart}`,
|
|
`DTEND;TZID=America/Toronto:${dtEnd}`,
|
|
`SUMMARY:${esc(summary)}`,
|
|
location ? `LOCATION:${esc(location)}` : null,
|
|
`DESCRIPTION:${desc}`,
|
|
`STATUS:${icalStatus}`,
|
|
// Recurrence rule (if recurring template)
|
|
job.is_recurring && job.recurrence_rule ? `RRULE:${job.recurrence_rule}${job.recurrence_end ? ';UNTIL=' + job.recurrence_end.replace(/-/g, '') + 'T235959' : ''}` : null,
|
|
// Color hint (non-standard but supported by some clients)
|
|
job.priority === 'high' ? 'PRIORITY:1' : job.priority === 'medium' ? 'PRIORITY:5' : 'PRIORITY:9',
|
|
'END:VEVENT',
|
|
)
|
|
}
|
|
|
|
lines.push('END:VCALENDAR')
|
|
return lines.filter(Boolean).join('\r\n')
|
|
}
|
|
|
|
async function handleCalendar (req, res, techId, query) {
|
|
try {
|
|
const token = query?.get?.('token') || ''
|
|
if (!validateToken(techId, token)) {
|
|
return json(res, 403, { error: 'Invalid or missing token. Get the link from the dispatch app.' })
|
|
}
|
|
|
|
// Fetch tech info
|
|
const techRes = await erpFetch(`/api/resource/Dispatch Technician?filters=${encodeURIComponent(JSON.stringify({ technician_id: techId }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name']))}&limit_page_length=1`)
|
|
const techs = techRes.data?.data || []
|
|
if (!techs.length) return json(res, 404, { error: 'Tech not found' })
|
|
const tech = techs[0]
|
|
|
|
// Fetch jobs for this tech: past 7 days + future 60 days
|
|
const now = new Date()
|
|
const past = new Date(now); past.setDate(past.getDate() - 7)
|
|
const future = new Date(now); future.setDate(future.getDate() + 60)
|
|
const fromStr = past.toISOString().slice(0, 10)
|
|
const toStr = future.toISOString().slice(0, 10)
|
|
|
|
const jobRes = await erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
|
|
['assigned_tech', '=', techId],
|
|
['scheduled_date', '>=', fromStr],
|
|
['scheduled_date', '<=', toStr],
|
|
['status', '!=', 'cancelled'],
|
|
]))}&fields=${encodeURIComponent(JSON.stringify([
|
|
'name', 'ticket_id', 'subject', 'address', 'scheduled_date', 'start_time',
|
|
'duration_h', 'priority', 'status', 'customer', 'notes',
|
|
'is_recurring', 'recurrence_rule', 'recurrence_end',
|
|
]))}&limit_page_length=500&order_by=${encodeURIComponent('scheduled_date asc, start_time asc')}`)
|
|
|
|
const jobs = jobRes.data?.data || []
|
|
const ical = buildICal(tech.full_name, jobs, techId)
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/calendar; charset=utf-8',
|
|
'Content-Disposition': `inline; filename="${techId}.ics"`,
|
|
'Cache-Control': 'no-cache, max-age=0',
|
|
})
|
|
res.end(ical)
|
|
} catch (e) {
|
|
log('iCal error:', e.message)
|
|
json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
module.exports = { handleCalendar, buildICal, generateToken }
|