1// =========================== 2// Two-Way SMS Messaging 3// Via Twilio (not tech's personal number) 4// Uses Supabase realtime for live updates 5// =========================== 6 7const Messages = { 8 activeConversation: null, 9 realtimeChannel: null, 10 11 templates: { 12 'onmyway': 'On my way! I\'ll be there in approximately 30 minutes.', 13 'running-late': 'Running a little behind schedule, but I\'m on my way. Thank you for your patience!', 14 'confirm': 'Hi! Just confirming your appointment with DuctDudes today. We\'ll see you soon!', 15 'access': 'We\'ll need access to your furnace room for the service. Could you please make sure it is accessible when we arrive?', 16 'complete': 'Your service is now complete. Thank you for choosing DuctDudes!', 17 'questions': 'Please do not hesitate to reach out if you have any questions about today\'s service.', 18 }, 19 20 async init() { 21 await this.loadConversations(); 22 this.renderConversations(); 23 }, 24 25 async loadConversations() { 26 // Try loading from database first 27 if (typeof supabase !== 'undefined' && supabase && AppState.tech.id) { 28 try { 29 const dbConversations = await DataService.messages.getConversationsForTech(AppState.tech.id); 30 if (dbConversations.length > 0) { 31 AppState.messages.conversations = dbConversations.map(c => ({ 32 clientId: c.clientId, 33 clientName: c.clientName, 34 clientPhone: c.clientPhone, 35 lastMessage: c.lastMessage || '', 36 lastTime: c.lastTime ? new Date(c.lastTime).getTime() : null, 37 unread: c.unread, 38 unreadCount: c.unreadCount, 39 messages: [], 40 })); 41 } 42 } catch (err) { 43 console.error('Failed to load conversations:', err); 44 } 45 } 46 47 // Also add today's job clients who don't have conversations yet. 48 // Schedule.loadSchedule mixes jobs, assessments, and tasks into the same 49 // array â only real jobs have a `client` object. Tasks/assessments use a 50 // flat `client_name` string and would crash `job.client.id` below. 51 const allJobs = Object.values(AppState.schedule.jobs).flat() 52 .filter(j => j._type !== 'task' && j._type !== 'assessment' && j.client); 53 const existing = new Set(AppState.messages.conversations.map(c => c.clientId)); 54 55 allJobs.forEach(job => { 56 if (existing.has(job.client.id)) return; 57 existing.add(job.client.id); 58 59 AppState.messages.conversations.push({ 60 clientId: job.client.id, 61 clientName: job.client.name, 62 clientPhone: job.client.phone, 63 jobId: job.id, 64 lastMessage: '', 65 lastTime: null, 66 unread: false, 67 unreadCount: 0, 68 messages: [], 69 }); 70 }); 71 }, 72 73 getHiddenConversations() { 74 try { return JSON.parse(localStorage.getItem('hiddenConvos') || '[]'); } catch { return []; } 75 }, 76 77 hideConversation(clientId) { 78 const hidden = this.getHiddenConversations(); 79 if (!hidden.includes(clientId)) hidden.push(clientId); 80 localStorage.setItem('hiddenConvos', JSON.stringify(hidden)); 81 AppState.messages.conversations = AppState.messages.conversations.filter(c => c.clientId !== clientId); 82 this.renderConversations(); 83 }, 84 85 renderConversations() { 86 const container = document.getElementById('conversationList'); 87 container.innerHTML = ''; 88 89 // Filter out hidden conversations (unless they have unread â new message brings them back) 90 const hidden = this.getHiddenConversations(); 91 const visible = AppState.messages.conversations.filter(c => !hidden.includes(c.clientId) || c.unread); 92 93 if (visible.length === 0) { 94 container.innerHTML = '<div class="no-jobs"><p>No conversations yet</p></div>'; 95 // Update badge 96 const badge = document.getElementById('msgBadge'); 97 if (badge) badge.style.display = 'none'; 98 return; 99 } 100 101 // Sort: unread first, then by most recent 102 const sorted = [...visible].sort((a, b) => { 103 if (a.unread && !b.unread) return -1; 104 if (!a.unread && b.unread) return 1; 105 return (b.lastTime || 0) - (a.lastTime || 0); 106 }); 107 108 // Group by date so we can add date separators 109 const today = new Date(); today.setHours(0, 0, 0, 0); 110 const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); 111 const dayLabel = (ts) => { 112 if (!ts) return 'Earlier'; 113 const d = new Date(ts); d.setHours(0, 0, 0, 0); 114 if (d.getTime() === today.getTime()) return 'Today'; 115 if (d.getTime() === yesterday.getTime()) return 'Yesterday'; 116 return d.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined }); 117 }; 118 119 let lastLabel = null; 120 sorted.forEach(convo => { 121 const label = dayLabel(convo.lastTime); 122 if (label !== lastLabel) { 123 lastLabel = label; 124 const sep = document.createElement('div'); 125 sep.style.cssText = 'font-size:11px;font-weight:600;color:#aaa;text-transform:uppercase;letter-spacing:0.5px;padding:10px 16px 4px;'; 126 sep.textContent = label; 127 container.appendChild(sep); 128 } 129 130 const timeStr = convo.lastTime ? this.formatConvoTime(convo.lastTime) : ''; 131 132 const item = document.createElement('div'); 133 item.className = 'conversation-item'; 134 item.style.position = 'relative'; 135 136 item.innerHTML = ` 137 <div class="convo-info" style="flex:1;min-width:0;"> 138 <div class="convo-name">${convo.clientName}</div> 139 <div class="convo-preview">${convo.lastMessage || 'No messages yet'}</div> 140 </div> 141 <div style="display:flex;flex-direction:column;align-items:flex-end;
141gap:4px;flex-shrink:0;margin-left:8px;"> 142 ${timeStr ? `<span class="convo-time">${timeStr}</span>` : ''} 143 ${convo.unread ? '<div class="convo-unread"></div>' : ''} 144 <button class="convo-delete-btn" style="background:none;border:none;cursor:pointer;font-size:15px;color:#ccc;padding:0;line-height:1;" title="Hide conversation">✕</button> 145 </div> 146 `; 147 148 item.querySelector('.convo-delete-btn').addEventListener('click', (e) => { 149 e.stopPropagation(); 150 this.hideConversation(convo.clientId); 151 }); 152 153 item.addEventListener('click', () => { 154 this.openChat(convo); 155 }); 156 157 container.appendChild(item); 158 }); 159 160 // Update badge 161 const unreadCount = AppState.messages.conversations.filter(c => c.unread).length; 162 const badge = document.getElementById('msgBadge'); 163 if (badge) { 164 if (unreadCount > 0) { 165 badge.textContent = unreadCount; 166 badge.style.display = 'flex'; 167 } else { 168 badge.style.display = 'none'; 169 } 170 } 171 }, 172 173 async openChat(convo) { 174 this.activeConversation = convo; 175 convo.unread = false; 176 convo.unreadCount = 0; 177 178 document.getElementById('chatClientName').textContent = convo.clientName; 179 180 convo.replyHistoryReady = false; 181 convo.replyAnchorId = null; 182 // Load full message history from database 183 if (typeof supabase !== 'undefined' && supabase && convo.clientId) { 184 try { 185 const dbMessages = await DataService.messages.getByClient(convo.clientId); 186 if (this.activeConversation !== convo) return; 187 convo.messages = dbMessages.map(m => ({ 188 id: m.id, 189 text: m.message, 190 channel: m.channel, 191 phoneFrom: m.phone_from, 192 phoneTo: m.phone_to, 193 direction: m.direction === 'outbound' ? 'sent' : 'received', 194 timestamp: new Date(m.created_at).getTime(), 195 })); 196 197 convo.replyHistoryReady = true; 198 // Mark as read 199 await DataService.messages.markRead(convo.clientId); 200 } catch (err) { 201 console.error('Failed to load messages:', err); 202 } 203 } 204 205 if (this.activeConversation !== convo) return; 206 this.renderMessages(); 207 this.bindChatEvents(); 208 209 Screens.show('chat'); 210 211 // Subscribe to realtime messages for this client 212 this.startRealtime(convo.clientId); 213 }, 214 215 renderMessages() { 216 const renderedConversation = this.activeConversation; 217 const container = document.getElementById('chatMessages'); 218 container.innerHTML = ''; 219 220 if (this.activeConversation.messages.length === 0) { 221 container.innerHTML = ` 222 <div style="text-align:center;padding:40px 20px;color:#888;"> 223 <p>Send a message to ${this.activeConversation.clientName}</p> 224 <p style="font-size:12px;margin-top:8px;">Messages are sent from your DuctDudes business number, not your personal phone.</p> 225 </div> 226 `; 227 return; 228 } 229 230 // Day-boundary separator helper â same labels as the conversation list 231 const today = new Date(); today.setHours(0, 0, 0, 0); 232 const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); 233 const dayLabel = (ts) => { 234 const d = new Date(ts); d.setHours(0, 0, 0, 0); 235 if (d.getTime() === today.getTime()) return 'Today'; 236 if (d.getTime() === yesterday.getTime()) return 'Yesterday'; 237 return d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined }); 238 }; 239 240 let lastDayKey = null; 241 this.activeConversation.messages.forEach(msg => { 242 // Insert a date separator whenever the day rolls over between messages 243 if (msg.timestamp) { 244 const d = new Date(msg.timestamp); d.setHours(0, 0, 0, 0); 245 const key = d.getTime(); 246 if (key !== lastDayKey) { 247 lastDayKey = key; 248 const sep = document.createElement('div'); 249 sep.className = 'chat-day-separator'; 250 sep.innerHTML = `<span>${dayLabel(msg.timestamp)}</span>`; 251 container.appendChild(sep); 252 } 253 } 254 255 const bubble = document.createElement('div'); 256 bubble.className = `chat-bubble ${msg.direction === 'sent' ? 'sent' : 'received'}`; 257 bubble.innerHTML = ` 258 <div>${msg.text}</div> 259 <div class="chat-time">${this.formatTime(msg.timestamp)}</div> 260 `; 261 if (msg.direction === 'received' && msg.channel === 'sms' && msg.id && msg.phoneTo && msg.phoneFrom) { 262 const replyButton = document.createElement('button');
263 replyButton.type = 'button'; 264 replyButton.textContent = (this.activeConversation.replyAnchorId === msg.id ? 'Replying from ' : 'Reply from ') + msg.phoneTo; 265 replyButton.addEventListener('click', () => { 266 if (this.activeConversation !== renderedConversation) return; 267 this.activeConversation.replyAnchorId = msg.id; 268 this.renderMessages(); 269 document.getElementById('chatInput').focus(); 270 }); 271 bubble.appendChild(replyButton); 272 } 273 container.appendChild(bubble); 274 }); 275 276 container.scrollTop = container.scrollHeight; 277 }, 278 279 bindChatEvents() { 280 const input = document.getElementById('chatInput'); 281 const sendBtn = document.getElementById('btnSendMessage'); 282 283 // Remove old listeners by cloning 284 const newSendBtn = sendBtn.cloneNode(true); 285 sendBtn.parentNode.replaceChild(newSendBtn, sendBtn); 286 287 const newInput = input.cloneNode(true); 288 input.parentNode.replaceChild(newInput, input); 289 290 newSendBtn.addEventListener('click', () => this.sendMessage()); 291 newInput.addEventListener('keypress', (e) => { 292 if (e.key === 'Enter') this.sendMessage(); 293 }); 294 295 // Quick-reply templates toggle 296 const templatesBtn = document.getElementById('btnTemplates'); 297 const templatesRow = document.getElementById('chatTemplatesRow'); 298 const newTemplatesBtn = templatesBtn.cloneNode(true); 299 templatesBtn.parentNode.replaceChild(newTemplatesBtn, templatesBtn); 300 301 newTemplatesBtn.addEventListener('click', () => { 302 const isOpen = templatesRow.classList.toggle('open'); 303 newTemplatesBtn.classList.toggle('active', isOpen); 304 }); 305 306 templatesRow.querySelectorAll('.chat-template-chip').forEach(chip => { 307 chip.addEventListener('click', () => { 308 const text = Messages.templates[chip.dataset.template] || ''; 309 document.getElementById('chatInput').value = text; 310 document.getElementById('chatInput').focus(); 311 templatesRow.classList.remove('open'); 312 newTemplatesBtn.classList.remove('active'); 313 }); 314 }); 315 }, 316 317 async sendMessage() { 318 const input = document.getElementById('chatInput'); 319 const text = input.value.trim(); 320 if (!text || !this.activeConversation) return; 321 322 // Existing inbound threads must use the verified receiving line, never the send default. 323 if (this.activeConversation.replyHistoryReady !== true) { 324 alert('Reload this conversation before sending so its receiving line can be verified.'); 325 return; 326 } 327 if (this.activeConversation.messages.some(m => m.direction === 'received')) { 328 return this.sendAnchoredReply(text, input); 329 } 330 331 // Add to local messages immediately (optimistic UI) 332 const msg = { 333 text, 334 direction: 'sent', 335 timestamp: Date.now(), 336 }; 337 this.activeConversation.messages.push(msg); 338 this.activeConversation.lastMessage = text; 339 this.activeConversation.lastTime = Date.now(); 340 341 input.value = ''; 342 this.renderMessages(); 343 344 // Send via Twilio API (also saves to database on server side) 345 const clientPhone = this.activeConversation.clientPhone; 346 if (clientPhone) { 347 try { 348 const res = await DataService.staffFetch('/api/sms/send', { 349 method: 'POST', 350 headers: { 'Content-Type': 'application/json' }, 351 body: JSON.stringify({ 352 to: clientPhone, 353 body: text, 354 from: AppState.tech.twilioNumber || null, 355 clientId: this.activeConversation.clientId, 356 technicianId: AppState.tech.id, 357 jobId: this.activeConversation.jobId || null, 358 }), 359 }); 360 const result = await res.json(); 361 if (result.success) { 362 msg.sid = result.sid; 363 console.log('SMS sent via Twilio:', result.sid); 364 } else { 365 console.error('SMS send failed:', result.error); 366 } 367 } catch (err) { 368 console.error('SMS API error, saving to DB only:', err); 369 // Fallback: save to database without Twilio send 370 if (typeof supabase !== 'undefined' && supabase && AppState.tech.id) { 371 try { 372 await DataService.messages.send({ 373 client_id: this.activeConversation.clientId, 374 job_id: this.activeConversation.jobId || null, 375 technician_id: AppState.tech.id, 376 sender_type: 'tech', 377 direction: 'outbound', 378 channel: 'sms', 379 phone_to: clientPhone, 380 message: text, 381 }); 382 } catch (dbErr) { 383 console.error('DB fallback failed:', dbErr); 384 } 385 } 386 } 387 } 388 }, 389 390 replyAnchor(convo) { 391 const incoming = convo.messages.filter(m => m.direction === 'received'); 392 const eligible = incoming.filter(m => m.channel === 'sms' && m.id && /^\+1[0-9]{10}$/.test(m.phoneFrom || '') && /^\+1[0-9]{10}$/.test(m.phoneTo || '')); 393 if (convo.replyAnchorId) { 394 const selected = eligible.find(m => m.id === convo.replyAnchorId); 395 if (!selected) throw new Error('Selected incoming message is unavailable. Choose Reply again.'); 396 return selected; 397 } 398 if (!eligible.length || eligible.length !== incoming.length) throw new Error('Choose Reply on a complete incoming SMS before sending.'); 399 const pairs = new Set(eligible.map(m => m.phoneTo + '|' + m.phoneFrom)); 400 if (pairs.size !== 1) throw new Error('This conversation contains multiple receiving lines or customer numbers. Choose Reply on the incoming message you want to answer.'); 401 return eligible.reduce((latest,m) => (m.timestamp || 0) > (latest.timestamp || 0) ? m : latest); 402 }, 403 404 async sendAnchoredReply(text, input) { 405 const convo = this.activeConversation; 406 if (convo.replyPending) return; 407 convo.replyPending = true; 408 let requestStarted = false;
409 let responseReceived = false; 410 try { 411 const anchor = this.replyAnchor(convo); 412 const { data, error } = await supabase.auth.getSession(); 413 if (error || !data?.session?.access_token) throw new Error('Sign in again before replying.'); 414 requestStarted = true; 415 const res = await fetch('/api/sms/reply', { 416 method: 'POST', 417 headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + data.session.access_token }, 418 body: JSON.stringify({ inboundMessageId: anchor.id, body: text }), 419 }); 420 const result = await res.json(); 421 responseReceived = true; 422 if (!res.ok || !result.success) throw new Error(result.error || 'Reply status is unconfirmed. Check message history before trying again.'); 423 convo.messages.push({ text, direction: 'sent', timestamp: Date.now(), sid: result.sid, channel: 'sms', phoneFrom: result.from, phoneTo: result.to }); 424 convo.lastMessage = text; 425 convo.lastTime = Date.now(); 426 if (this.activeConversation === convo) { if (input.value.trim() === text) input.value = ''; this.renderMessages(); } 427 } catch (err) { 428 alert(requestStarted && !responseReceived ? 'Reply status is unconfirmed. Check message history before trying again.' : (err.message || 'Reply status is unconfirmed. Check message history before trying again.')); 429 } finally { 430 convo.replyPending = false; 431 } 432 }, 433 434 // Realtime subscription for incoming messages 435 startRealtime(clientId) { 436 this.stopRealtime(); 437 438 if (typeof supabase === 'undefined' || !supabase) return; 439 440 this.realtimeChannel = supabase 441 .channel('messages-' + clientId) 442 .on('postgres_changes', { 443 event: 'INSERT', 444 schema: 'public', 445 table: 'messages', 446 filter: 'client_id=eq.' + clientId, 447 }, (payload) => { 448 const newMsg = payload.new; 449 // Only add inbound messages (outbound ones we already added optimistically) 450 if (newMsg.direction === 'inbound' && this.activeConversation?.clientId === clientId) { 451 this.activeConversation.messages.push({ 452 id: newMsg.id, 453 text: newMsg.message, 454 channel: newMsg.channel, 455 phoneFrom: newMsg.phone_from, 456 phoneTo: newMsg.phone_to, 457 direction: 'received', 458 timestamp: new Date(newMsg.created_at).getTime(), 459 }); 460 this.activeConversation.lastMessage = newMsg.message; 461 this.activeConversation.lastTime = new Date(newMsg.created_at).getTime(); 462 this.renderMessages(); 463 464 // Mark as read since chat is open 465 DataService.messages.markRead(clientId).catch(() => {}); 466 } 467 }) 468 .subscribe(); 469 }, 470 471 stopRealtime() { 472 if (this.realtimeChannel) { 473 supabase.removeChannel(this.realtimeChannel); 474 this.realtimeChannel = null; 475 } 476 }, 477 478 // Helpers 479 formatTime(timestamp) { 480 if (!timestamp) return ''; 481 const date = new Date(timestamp); 482 return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); 483 }, 484 485 // For conversation list: show time if today, date if older 486 formatConvoTime(timestamp) { 487 if (!timestamp) return ''; 488 const date = new Date(timestamp); 489 const today = new Date(); today.setHours(0, 0, 0, 0); 490 const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0); 491 if (msgDay.getTime() === today.getTime()) { 492 return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); 493 } 494 return date.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); 495 }, 496};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.