'use strict' const cfg = require('./config') const { log, json, parseBody, erpFetch, httpRequest } = require('./helpers') // ── Supabase Address Search (RQA — Répertoire Québécois des Adresses) ─────── const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co' const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs' async function searchAddresses (term, limit = 8) { const clean = term.trim() if (clean.length < 3) return [] // Parse civic number and street parts const numMatch = clean.match(/^\s*(\d+)\s*(.*)/) const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY } const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse' const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}` let results = [] // Build ilike pattern from words: "chemin du lac" → "*chemin*lac*" function wordsToIlike (str) { const words = str.split(/\s+/).filter(w => w.length >= 2) if (!words.length) return '' return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*' } if (numMatch) { // Have civic number — fast exact match on numero_municipal + ilike on street const num = numMatch[1] const street = numMatch[2].trim() let url = `${base}&numero_municipal=eq.${num}` if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}` url += '&order=nom_municipalite' const res = await httpRequest(url, '', { headers }) results = Array.isArray(res.data) ? res.data : [] // If no exact match, try prefix on numero_municipal if (!results.length && num.length >= 2) { let url2 = `${base}&numero_municipal=like.${num}*` if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}` url2 += '&order=nom_municipalite' const res2 = await httpRequest(url2, '', { headers }) results = Array.isArray(res2.data) ? res2.data : [] } } else { // No civic number — search by street name only const pattern = wordsToIlike(clean) if (!pattern) return [] let url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite` const res = await httpRequest(url, '', { headers }) results = Array.isArray(res.data) ? res.data : [] } // Check fiber availability for results return results.map(a => ({ ...a, fiber_available: false, // TODO: join with fiber_availability table })) } // ── OTP store (in-memory, TTL 10 min) ─────────────────────────────────────── const otpStore = new Map() // key = phone/email → { code, expires, customerId, customerName } function generateOTP () { return String(Math.floor(100000 + Math.random() * 900000)) } async function sendOTP (identifier) { const isEmail = identifier.includes('@') const code = generateOTP() const expires = Date.now() + 10 * 60 * 1000 // 10 min // Lookup customer in ERPNext let customer = null if (isEmail) { const res = await erpFetch(`/api/resource/Customer?filters=${encodeURIComponent(JSON.stringify([['email_id', '=', identifier]]))}&fields=["name","customer_name","cell_phone","email_id"]&limit_page_length=1`) if (res.status === 200 && res.data?.data?.length) customer = res.data.data[0] } else { const { lookupCustomerByPhone } = require('./helpers') customer = await lookupCustomerByPhone(identifier) } if (!customer) return { found: false } otpStore.set(identifier, { code, expires, customerId: customer.name, customerName: customer.customer_name }) // Send code if (isEmail) { const { sendEmail } = require('./email') await sendEmail({ to: identifier, subject: 'Code de vérification Gigafibre', html: `

Vérification

Votre code de vérification est :

${code}

Ce code expire dans 10 minutes.

`, }) } else { try { const { sendSmsInternal } = require('./twilio') await sendSmsInternal(identifier, `Gigafibre — Votre code de vérification : ${code}\nExpire dans 10 min.`) } catch (e) { log('OTP SMS failed:', e.message) } } log(`OTP sent to ${identifier} for customer ${customer.name}`) return { found: true, sent: true, channel: isEmail ? 'email' : 'sms' } } async function verifyOTP (identifier, code) { const entry = otpStore.get(identifier) if (!entry) return { valid: false, reason: 'no_otp' } if (Date.now() > entry.expires) { otpStore.delete(identifier); return { valid: false, reason: 'expired' } } if (entry.code !== code) return { valid: false, reason: 'wrong_code' } otpStore.delete(identifier) // Fetch customer details + addresses const result = { valid: true, customer_id: entry.customerId, customer_name: entry.customerName } try { // Get customer details (phone, email) const custRes = await erpFetch(`/api/resource/Customer/${encodeURIComponent(entry.customerId)}?fields=["name","customer_name","cell_phone","email_id","tel_home"]`) if (custRes.status === 200 && custRes.data?.data) { const c = custRes.data.data result.phone = c.cell_phone || c.tel_home || '' result.email = c.email_id || '' } // If no email on Customer, fetch from linked Contact if (!result.email) { try { const contRes = await erpFetch(`/api/resource/Contact?filters=${encodeURIComponent(JSON.stringify([['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]]))}&fields=["email_id"]&limit_page_length=1`) if (contRes.status === 200 && contRes.data?.data?.[0]?.email_id) { result.email = contRes.data.data[0].email_id } } catch (e) { log('OTP - Contact email fallback error:', e.message) } } // Get Service Locations (addresses) const locRes = await erpFetch(`/api/resource/Service%20Location?filters=${encodeURIComponent(JSON.stringify([['customer', '=', entry.customerId]]))}&fields=["name","address_line","city","postal_code","location_name","latitude","longitude"]&limit_page_length=20`) if (locRes.status === 200 && locRes.data?.data?.length) { result.addresses = locRes.data.data.map(l => ({ name: l.name, address: l.address_line || l.location_name || l.name, city: l.city || '', postal_code: l.postal_code || '', latitude: l.latitude || null, longitude: l.longitude || null, })) } } catch (e) { log('OTP verify - customer details fetch error:', e.message) } return result } // ── Product Catalog API ───────────────────────────────────────────────────── // Serves the product catalog from ERPNext Items with custom fields async function getCatalog () { const fields = JSON.stringify([ 'name', 'item_code', 'item_name', 'item_group', 'standard_rate', 'description', 'image', 'project_template_id', 'requires_visit', 'delivery_method', 'is_bundle_parent', 'billing_type', 'service_category', ]) const filters = JSON.stringify([['is_sales_item', '=', 1], ['disabled', '=', 0]]) const res = await erpFetch(`/api/resource/Item?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=100&order_by=service_category,standard_rate`) if (res.status !== 200) { log('Catalog fetch failed:', res.status) return [] } return (res.data.data || []).map(item => ({ item_code: item.item_code, item_name: item.item_name, item_group: item.item_group, rate: item.standard_rate || 0, description: item.description || '', image: item.image || '', project_template_id: item.project_template_id || '', requires_visit: !!item.requires_visit, delivery_method: item.delivery_method || '', is_bundle: !!item.is_bundle_parent, billing_type: item.billing_type || '', service_category: item.service_category || '', })) } // ── Checkout / Order Processing ───────────────────────────────────────────── // Creates Sales Order + Dispatch Jobs from cart items + project template async function processCheckout (body) { // Accept both flat format and nested { contact: {...}, installation: {...} } format const contact = body.contact || {} const installation = body.installation || {} const items = body.items || [] const customer_name = body.customer_name || contact.name || '' const phone = body.phone || contact.phone || '' const email = body.email || contact.email || '' const address = body.address || contact.address || '' const city = body.city || contact.city || '' const postal_code = body.postal_code || contact.postal_code || '' const preferred_date = body.preferred_date || installation.preferred_date || '' const preferred_slot = body.preferred_slot || installation.preferred_slot || '' const delivery_method = body.delivery_method || '' const notes = body.notes || '' const mode = body.mode || 'postpaid' const payment_intent_id = body.payment_intent_id || '' const accepted_terms = body.accepted_terms log('Checkout request:', { customer_name, phone, email, items: items.length, mode }) if (!items?.length) throw new Error('Aucun article dans le panier') if (!customer_name) throw new Error('Nom du client requis') if (!phone && !email) throw new Error('Téléphone ou courriel requis') const today = new Date().toISOString().split('T')[0] const result = { ok: true, items_count: items.length } // ── 1. Find or create Customer ── let customerName = '' const providedCustomerId = body.customer_id || '' try { // If OTP-verified customer_id provided, use it directly if (providedCustomerId) { customerName = providedCustomerId result.customer_existing = true log('Using OTP-verified customer:', customerName) } // Search by phone const { lookupCustomerByPhone } = require('./helpers') if (!customerName && phone) { const existing = await lookupCustomerByPhone(phone) if (existing) { customerName = existing.name result.customer_existing = true } } // Create if not found if (!customerName) { const custPayload = { customer_name: customer_name, customer_type: 'Individual', customer_group: 'Individual', territory: 'Canada', cell_phone: phone || '', email_id: email || '', } log('Creating customer:', custPayload) const custRes = await erpFetch('/api/resource/Customer', { method: 'POST', body: custPayload, }) log('Customer creation result:', custRes.status, custRes.data?.data?.name || JSON.stringify(custRes.data).substring(0, 200)) if (custRes.status === 200 && custRes.data?.data) { customerName = custRes.data.data.name result.customer_created = true } else { // Fallback: use name as-is customerName = customer_name } } } catch (e) { log('Customer lookup/create failed:', e.message) customerName = customer_name } // ── 2. Create Sales Order ── const onetimeItems = items.filter(i => i.billing_type !== 'Mensuel' && i.billing_type !== 'Annuel') const recurringItems = items.filter(i => i.billing_type === 'Mensuel' || i.billing_type === 'Annuel') const allItems = items.map(i => ({ item_code: i.item_code, item_name: i.item_name, qty: i.qty || 1, rate: i.rate, description: i.billing_type === 'Mensuel' ? `${i.item_name} — ${i.rate}$/mois` : i.billing_type === 'Annuel' ? `${i.item_name} — ${i.rate}$/an` : i.item_name, })) let orderName = '' try { const soPayload = { customer: customerName, company: 'TARGO', currency: 'CAD', selling_price_list: 'Standard Selling', transaction_date: today, delivery_date: preferred_date || today, items: allItems, terms: notes || '', } const soRes = await erpFetch('/api/resource/Sales%20Order', { method: 'POST', body: JSON.stringify(soPayload), }) if (soRes.status === 200 && soRes.data?.data) { orderName = soRes.data.data.name result.sales_order = orderName log(`Sales Order created: ${orderName} for ${customerName}`) } else { log('Sales Order creation failed:', soRes.status, JSON.stringify(soRes.data).substring(0, 300)) result.sales_order_error = `ERPNext ${soRes.status}` } } catch (e) { log('Sales Order creation failed:', e.message) result.sales_order_error = e.message } // ── 3. Create Subscriptions for recurring items ── for (const item of recurringItems) { try { // Ensure plan exists let planName = null try { const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code)}`) if (findRes.status === 200) planName = findRes.data?.data?.name } catch {} if (!planName) { const planRes = await erpFetch('/api/resource/Subscription%20Plan', { method: 'POST', body: JSON.stringify({ plan_name: item.item_code, item: item.item_code, currency: 'CAD', price_determination: 'Fixed Rate', cost: item.rate, billing_interval: item.billing_type === 'Annuel' ? 'Year' : 'Month', billing_interval_count: 1, }), }) if (planRes.status === 200) planName = planRes.data?.data?.name } if (planName) { await erpFetch('/api/resource/Subscription', { method: 'POST', body: JSON.stringify({ party_type: 'Customer', party: customerName, company: 'TARGO', start_date: today, generate_invoice_at: 'Beginning of the current subscription period', days_until_due: 30, follow_calendar_months: 1, plans: [{ plan: planName, qty: item.qty || 1 }], }), }) log(`Subscription created for ${item.item_code}`) } } catch (e) { log(`Subscription failed for ${item.item_code}:`, e.message) } } // ── 4. Create Dispatch Jobs from project templates ── // Collect unique templates needed const templatesNeeded = new Set() for (const item of items) { if (item.project_template_id) templatesNeeded.add(item.project_template_id) } log('Templates needed:', [...templatesNeeded], 'from items:', items.map(i => `${i.item_code}:${i.project_template_id || 'none'}`)) const createdJobs = [] if (templatesNeeded.size > 0) { const fullAddress = [address, city, postal_code].filter(Boolean).join(', ') for (const templateId of templatesNeeded) { // Fetch template steps (stored in frontend config, we replicate here) const steps = getTemplateSteps(templateId) if (!steps.length) continue for (let i = 0; i < steps.length; i++) { const step = steps[i] const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i let dependsOn = '' if (step.depends_on_step !== null && step.depends_on_step !== undefined) { const depJob = createdJobs[step.depends_on_step] if (depJob) dependsOn = depJob.name } const parentJob = createdJobs.length > 0 ? createdJobs[0].name : '' const jobPayload = { ticket_id: ticketId, subject: step.subject, address: fullAddress, duration_h: step.duration_h || 1, priority: step.priority || 'medium', status: 'open', job_type: step.job_type || 'Autre', customer: customerName, depends_on: dependsOn, parent_job: parentJob, step_order: i + 1, on_open_webhook: step.on_open_webhook || '', on_close_webhook: step.on_close_webhook || '', notes: [ step.assigned_group ? `Groupe: ${step.assigned_group}` : '', orderName ? `Sales Order: ${orderName}` : '', preferred_date ? `Date souhaitée: ${preferred_date} ${preferred_slot || ''}` : '', 'Commande en ligne', ].filter(Boolean).join(' | '), scheduled_date: preferred_date || '', } try { const jobRes = await erpFetch('/api/resource/Dispatch%20Job', { method: 'POST', body: JSON.stringify(jobPayload), }) if (jobRes.status === 200 && jobRes.data?.data) { createdJobs.push(jobRes.data.data) } } catch (e) { log(`Job creation failed: ${step.subject} — ${e.message}`) } } } result.jobs_created = createdJobs.length } // ── 5. Send confirmation SMS/email ── if (phone) { try { const { sendSmsInternal } = require('./twilio') const itemList = items.map(i => `• ${i.item_name}`).join('\n') const msg = `Gigafibre — Commande confirmée!\n\n${itemList}\n\n${orderName ? `Réf: ${orderName}\n` : ''}${preferred_date ? `Date souhaitée: ${preferred_date}\n` : ''}Nous vous contacterons pour confirmer les détails.\n\nMerci!` await sendSmsInternal(phone, msg) result.sms_sent = true } catch (e) { log('Checkout confirmation SMS failed:', e.message) } } if (email) { try { const { sendEmail } = require('./email') const itemRows = items.map(i => `${i.item_name}${i.rate.toFixed(2)}$${i.billing_type === 'Mensuel' ? '/mois' : i.billing_type === 'Annuel' ? '/an' : ''}` ).join('') await sendEmail({ to: email, subject: `Confirmation de commande${orderName ? ` ${orderName}` : ''} — Gigafibre`, html: `

Commande confirmée

${orderName || 'Merci pour votre commande'}

Bonjour ${customer_name},

${itemRows}
${preferred_date ? `

Date souhaitée : ${preferred_date} ${preferred_slot || ''}

` : ''}

Nous vous contacterons pour confirmer les détails.

Gigafibre — Targo Télécommunications

`, }) result.email_sent = true } catch (e) { log('Checkout confirmation email failed:', e.message) } } result.order_id = orderName || ('CMD-' + Date.now().toString(36).toUpperCase()) return result } // ── Project template steps (replicated from frontend config) ──────────────── function getTemplateSteps (templateId) { const TEMPLATES = { fiber_install: [ { subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null }, { subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 }, { subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 }, { subject: 'Test de débit & validation client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 }, ], phone_service: [ { subject: 'Importer le numéro de téléphone', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null }, { subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 }, { subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 }, { subject: 'Validation et test du service téléphonique', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 }, ], move_service: [ { subject: 'Préparation déménagement (vérifier éligibilité nouveau site)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null }, { subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 }, { subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 }, { subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 }, ], repair_service: [ { subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null }, { subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 }, { subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 }, ], } return TEMPLATES[templateId] || [] } // ── HTTP Handler ──────────────────────────────────────────────────────────── async function handle (req, res, method, path) { // GET /api/catalog — Public product catalog if (path === '/api/catalog' && method === 'GET') { try { const catalog = await getCatalog() return json(res, 200, { ok: true, items: catalog }) } catch (e) { log('Catalog error:', e.message) return json(res, 500, { error: 'Failed to load catalog' }) } } // POST /api/checkout — Process order if (path === '/api/checkout' && method === 'POST') { try { const body = await parseBody(req) const result = await processCheckout(body) return json(res, 200, result) } catch (e) { log('Checkout error:', e.message) return json(res, 400, { ok: false, error: e.message }) } } // GET /api/orders — List recent orders (Sales Orders) if (path === '/api/orders' && method === 'GET') { try { const { URL } = require('url') const url = new URL(req.url, 'http://localhost') const limit = parseInt(url.searchParams.get('limit') || '20', 10) const customer = url.searchParams.get('customer') || '' const fields = JSON.stringify(['name', 'customer', 'customer_name', 'transaction_date', 'grand_total', 'status', 'creation']) let filters = [['docstatus', '=', 0]] if (customer) filters.push(['customer', '=', customer]) const fStr = JSON.stringify(filters) const soRes = await erpFetch(`/api/resource/Sales%20Order?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(fStr)}&order_by=creation desc&limit_page_length=${limit}`) return json(res, 200, { ok: true, orders: soRes.data?.data || [] }) } catch (e) { log('Orders list error:', e.message) return json(res, 500, { error: e.message }) } } // GET /api/order/:name — Single order detail if (path.startsWith('/api/order/') && method === 'GET') { try { const orderName = decodeURIComponent(path.replace('/api/order/', '')) const soRes = await erpFetch(`/api/resource/Sales%20Order/${encodeURIComponent(orderName)}`) if (soRes.status !== 200) return json(res, 404, { error: 'Commande non trouvée' }) return json(res, 200, { ok: true, order: soRes.data?.data }) } catch (e) { return json(res, 500, { error: e.message }) } } // POST /api/address-search — Address autocomplete (Supabase RQA) if (path === '/api/address-search' && method === 'POST') { try { const body = await parseBody(req) if (!body.q || body.q.length < 3) return json(res, 200, { results: [] }) const results = await searchAddresses(body.q, body.limit || 8) return json(res, 200, { results }) } catch (e) { log('Address search error:', e.message) return json(res, 500, { error: 'Address search failed' }) } } // POST /api/otp/send — Send OTP to existing customer if (path === '/api/otp/send' && method === 'POST') { try { const body = await parseBody(req) if (!body.identifier) return json(res, 400, { error: 'Email ou téléphone requis' }) const result = await sendOTP(body.identifier) return json(res, 200, result) } catch (e) { log('OTP send error:', e.message) return json(res, 500, { error: e.message }) } } // POST /api/otp/verify — Verify OTP code if (path === '/api/otp/verify' && method === 'POST') { try { const body = await parseBody(req) if (!body.identifier || !body.code) return json(res, 400, { error: 'identifier et code requis' }) const result = await verifyOTP(body.identifier, body.code) return json(res, 200, result) } catch (e) { return json(res, 500, { error: e.message }) } } // POST /api/accept-for-client — Agent accepts quotation on behalf of client if (path === '/api/accept-for-client' && method === 'POST') { try { const body = await parseBody(req) const { quotation, agent_name } = body if (!quotation) return json(res, 400, { error: 'quotation required' }) const { acceptQuotation, fetchQuotation } = require('./acceptance') // Intentionally not exported but we call it directly here const acceptance = require('./acceptance') // Record acceptance by agent const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || '' await acceptance.acceptQuotation(quotation, { method: 'Accepté par agent', contact: agent_name || req.headers['x-authentik-email'] || 'Agent', ip, userAgent: req.headers['user-agent'] || '', }) return json(res, 200, { ok: true, quotation, message: 'Devis accepté. Tâches créées.' }) } catch (e) { log('Accept-for-client error:', e.message) return json(res, 500, { error: e.message }) } } return json(res, 404, { error: 'Not found' }) } module.exports = { handle, getCatalog, processCheckout, getTemplateSteps }