gigafibre-fsm/services/targo-hub/lib/ical.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
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>
2026-04-13 08:39:58 -04:00

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 }