'use strict' const crypto = require('crypto') const cfg = require('./config') const { log, json } = require('./helpers') const erp = require('./erp') 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 techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id', 'full_name'], filters: [['technician_id', '=', techId]], limit: 1, }) 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 jobs = await erp.list('Dispatch Job', { fields: [ 'name', 'ticket_id', 'subject', 'address', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'status', 'customer', 'notes', 'is_recurring', 'recurrence_rule', 'recurrence_end', ], filters: [ ['assigned_tech', '=', techId], ['scheduled_date', '>=', fromStr], ['scheduled_date', '<=', toStr], ['status', '!=', 'cancelled'], ], orderBy: 'scheduled_date asc, start_time asc', limit: 500, }) 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 }