{
  "id": "gpsImapIngest0001",
  "name": "GPS: Ingesta IMAP \u2192 gps_event",
  "nodes": [
    {
      "parameters": {
        "format": "resolved",
        "postProcessAction": "read",
        "options": {}
      },
      "id": "ImapTrigger",
      "name": "Email Trigger (IMAP)",
      "type": "n8n-nodes-base.emailReadImap",
      "typeVersion": 2,
      "position": [
        -720,
        0
      ],
      "credentials": {
        "imap": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// --- SHA-256 en JS puro (equivalente a crypto.createHash('sha256')) ---\n// Generado por scripts/build_ingest_workflow.js \u2014 no editar a mano.\nconst crypto = (() => {\n  const K = [\n    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,\n    0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,\n    0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,\n    0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,\n    0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,\n    0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,\n    0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,\n    0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,\n    0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n  ];\n\n  const rotr = (x, n) => (x >>> n) | (x << (32 - n));\n\n  function sha256Hex(input) {\n    // El hash se calcula sobre los bytes UTF-8, igual que\n    // crypto.createHash('sha256').update(s, 'utf8').\n    const bytes = Array.from(new TextEncoder().encode(input));\n\n    const bitLen = bytes.length * 8;\n    bytes.push(0x80);\n    while (bytes.length % 64 !== 56) bytes.push(0);\n\n    // Longitud como entero de 64 bits big-endian.\n    const hi = Math.floor(bitLen / 0x100000000);\n    const lo = bitLen >>> 0;\n    bytes.push((hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff);\n    bytes.push((lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);\n\n    let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;\n    let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;\n\n    const w = new Uint32Array(64);\n\n    for (let i = 0; i < bytes.length; i += 64) {\n      for (let t = 0; t < 16; t++) {\n        w[t] =\n          (bytes[i + t * 4] << 24) |\n          (bytes[i + t * 4 + 1] << 16) |\n          (bytes[i + t * 4 + 2] << 8) |\n          bytes[i + t * 4 + 3];\n      }\n\n      for (let t = 16; t < 64; t++) {\n        const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3);\n        const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10);\n        w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;\n      }\n\n      let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;\n\n      for (let t = 0; t < 64; t++) {\n        const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n        const ch = (e & f) ^ (~e & g);\n        const temp1 = (h + S1 + ch + K[t] + w[t]) >>> 0;\n        const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n        const maj = (a & b) ^ (a & c) ^ (b & c);\n        const temp2 = (S0 + maj) >>> 0;\n\n        h = g; g = f; f = e;\n        e = (d + temp1) >>> 0;\n        d = c; c = b; b = a;\n        a = (temp1 + temp2) >>> 0;\n      }\n\n      h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0;\n      h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;\n      h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0;\n      h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0;\n    }\n\n    return [h0, h1, h2, h3, h4, h5, h6, h7]\n      .map((x) => x.toString(16).padStart(8, '0'))\n      .join('');\n  }\n\n  // Imita la interfaz encadenable que usa el parser.\n  return {\n    createHash() {\n      let data = '';\n      const api = {\n        update(chunk) { data += chunk; return api; },\n        digest() { return sha256Hex(data); },\n      };\n      return api;\n    },\n  };\n})();\n\nfunction stripHtml(html) {\n  if (!html) return '';\n  return String(html)\n    .replace(/<br\\s*\\/?>/gi, '\\n')\n    .replace(/<\\/p>/gi, '\\n')\n    .replace(/<\\/div>/gi, '\\n')\n    .replace(/<\\/pre>/gi, '\\n')\n    .replace(/<pre[^>]*>/gi, '')\n    .replace(/<a [^>]*>.*?<\\/a>/gi, '')\n    .replace(/<[^>]+>/g, '')\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&amp;/g, '&')\n    .replace(/&quot;/g, '\"')\n    .replace(/&#39;/g, \"'\")\n    .trim();\n}\n\nfunction toIsoFromDDMMYYYY_HHMMSS(s) {\n  const m = (s || '').match(/(\\d{2})\\.(\\d{2})\\.(\\d{4})\\s+(\\d{2}:\\d{2}:\\d{2})/);\n  if (!m) return null;\n  const [, dd, mm, yyyy, hhmmss] = m;\n  return `${yyyy}-${mm}-${dd}T${hhmmss}Z`;\n}\n\nfunction normalize(s) {\n  return (s || '')\n    .toLowerCase()\n    .normalize('NFD')\n    .replace(/[\\u0300-\\u036f]/g, '')\n    .trim();\n}\n\nfunction parseNum(s) {\n  if (s == null) return null;\n  const t = String(s).replace(',', '.').trim();\n  const n = Number(t);\n  return Number.isFinite(n) ? n : null;\n}\n\nfunction detectConnectionType(subject, body, normAll) {\n  const s = normalize(subject);\n  const all = normAll || normalize(`${subject} ${body}`);\n\n  if (\n    s.includes('perdida de conexion') ||\n    s.includes('p\u00e9rdida de conexi\u00f3n') ||\n    all.includes('conexion perdida') ||\n    all.includes('conexi\u00f3n perdida')\n  ) return 'CONNECTION_LOST';\n\n  if (\n    s.includes('se restablece conexion') ||\n    s.includes('se restablece conexi\u00f3n') ||\n    all.includes('conexion restaurada') ||\n    all.includes('conexi\u00f3n restaurada') ||\n    all.includes('conexion restablecida') ||\n    all.includes('conexi\u00f3n restablecida')\n  ) return 'CONNECTION_RESTORED';\n\n  return null;\n}\n\nfunction parseConnectionMinutesFromSubject(subject) {\n  let m = (subject || '').match(/(\\d+)\\s*MIN/i);\n  if (m) return Number(m[1]);\n\n  m = (subject || '').match(/\\b1H\\b/i);\n  if (m) return 60;\n\n  m = (subject || '').match(/\\b(\\d+)\\s*H\\b/i);\n  if (m) return Number(m[1]) * 60;\n\n  return null;\n}\n\nfunction pickUnitCode(subject, body) {\n  let m;\n\n  subject = subject || '';\n  body = body || '';\n\n  m =\n    subject.match(/\\(UNID\\s+([A-Z0-9-]+)\\)/i) ||\n    body.match(/\\bUNID\\s+([A-Z0-9-]+)\\b/i) ||\n    subject.match(/\\bUNID\\s+([A-Z0-9-]+)\\b/i);\n\n  if (m) return m[1].toUpperCase();\n\n  m = subject.match(/\\((V\\s*MOVIL\\s*\\d+)\\)/i);\n  if (m) {\n    return m[1].toUpperCase().replace(/\\s+/g, ' ').trim();\n  }\n\n  m =\n    subject.match(/\\(TRANSIT\\s+([A-Z0-9-]+)\\)/i) ||\n    body.match(/\\bTRANSIT\\s+([A-Z0-9-]+)\\b/i) ||\n    subject.match(/\\bTRANSIT\\s+([A-Z0-9-]+)\\b/i);\n\n  if (m) return m[1].toUpperCase();\n\n  m = subject.match(/\\(([A-Z]{2,10}-[A-Z0-9-]+)\\)/i);\n  if (m) return m[1].toUpperCase();\n\n  m =\n    subject.match(/\\b(T\\d{1,4})\\b/i) ||\n    subject.match(/\\b(V\\d{1,4})\\b/i);\n\n  if (m) return m[1].toUpperCase();\n\n  return null;\n}\n\nfunction buildSourceHash(hashInput) {\n  return crypto\n    .createHash('sha256')\n    .update(hashInput, 'utf8')\n    .digest('hex');\n}\n\nfunction parseGpsEmail(input) {\n  const subject = (input.subject || '').toString().trim();\n  const textPlain = (input.textPlain || input.text || '').toString();\n  const textHtml = (input.textHtml || '').toString();\n  const rawContent = textHtml || textPlain || '';\n  const body = textPlain || stripHtml(rawContent);\n\n  const mailDate = input.mailDate || input.date || null;\n  const email_message_id = input.email_message_id || input.messageId || null;\n  const imap_uid = input.imap_uid || input.uid || null;\n\n  const textAll = `${subject} ${body}`;\n  const norm = normalize(textAll);\n\n  const unit_code = pickUnitCode(subject, body);\n\n  let event_time = null;\n  let m = body.match(/(\\d{2}\\.\\d{2}\\.\\d{4}\\s+\\d{2}:\\d{2}:\\d{2})/);\n  if (m) {\n    event_time = toIsoFromDDMMYYYY_HHMMSS(m[1]);\n  } else if (mailDate) {\n    event_time = new Date(mailDate).toISOString();\n  }\n\n  let speed_kmh = null;\n  m =\n    body.match(/speed\\s+(\\d+(?:[.,]\\d+)?)\\s*km\\s*\\/?\\s*h/i) ||\n    body.match(/velocidad\\s+de\\s+(\\d+(?:[.,]\\d+)?)\\s*km\\s*\\/?\\s*h/i);\n  if (m) speed_kmh = parseNum(m[1]);\n\n  let address_text = null;\n  m =\n    body.match(/near\\s+'([^']+)'/i) ||\n    body.match(/cerca de\\s+'([^']+)'/i);\n  if (m) address_text = m[1].trim();\n\n  if (!address_text) {\n    m =\n      body.match(/near\\s+(.+?)(?:\\n|$)/i) ||\n      body.match(/cerca de\\s+(.+?)(?:\\n|$)/i);\n    if (m) address_text = m[1].trim().replace(/\\.$/, '');\n  }\n\n  let fuel_level_liters = null;\n  let odometer_km = null;\n\n  m = body.match(/Total\\s+de\\s+Combustible.*value\\s+of\\s+(\\d+(?:[.,]\\d+)?)\\s*l\\b/i);\n  if (m) fuel_level_liters = parseNum(m[1]);\n\n  m = body.match(/Odometro.*value\\s+of\\s+(\\d+(?:[.,]\\d+)?)\\s*km\\b/i);\n  if (m) odometer_km = parseNum(m[1]);\n\n  let event_type = 'OTHER';\n\n  const connType = detectConnectionType(subject, body, norm);\n  if (connType) {\n    event_type = connType;\n  } else {\n    if (subject.toLowerCase().includes('sensor de combustible') && fuel_level_liters != null) {\n      event_type = 'FUEL_LEVEL';\n    } else if (subject.toLowerCase().includes('mileage sensor') && odometer_km != null) {\n      event_type = 'MILEAGE';\n    } else {\n      const isDrain = norm.includes('descarga de combustible');\n      const isFill  = norm.includes('llenado de combustible');\n      const isIdle  = norm.includes('inactividad') || norm.includes('detenido');\n      const isCasetaEnter =\n        norm.includes('cruce de caseta') ||\n        norm.includes('entro en caseta') ||\n        norm.includes('entr\u00f3 en caseta');\n\n      if (isDrain) event_type = 'FUEL_DRAIN';\n      else if (isFill) event_type = 'FUEL_FILL';\n      else if (isCasetaEnter) event_type = 'CASETA_ENTER';\n      else if (isIdle) event_type = 'IDLE';\n    }\n  }\n\n  let fuel_liters = null;\n  m = textAll.match(/Llenado\\s+de\\s+Combustible\\s+(\\d+(?:[.,]\\d+)?)\\s*l\\b/i);\n  if (m) fuel_liters = parseNum(m[1]);\n\n  if (fuel_liters == null) {\n    m = textAll.match(/descarga\\s+de\\s+combustible\\s+de\\s+(\\d+(?:[.,]\\d+)?)\\s*l\\b/i);\n    if (m) fuel_liters = parseNum(m[1]);\n  }\n\n  let geofence_name = address_text || null;\n  let geofence_kind = 'OTHER';\n  const g = normalize(geofence_name || '');\n\n  if (g.includes('caseta')) geofence_kind = 'CASETA';\n  else if (g.includes('base') || g.includes('patio')) geofence_kind = 'BASE';\n  else if (g.includes('planta') || g.includes('cedis')) geofence_kind = 'PLANTA';\n  else if (g.includes('gasolinera') || g.includes('pemex') || g.includes('bp') || g.includes('shell')) {\n    geofence_kind = 'GASOLINERA';\n  }\n\n  let connection_minutes = null;\n  let connection_event = null;\n\n  if (event_type === 'CONNECTION_LOST') {\n    connection_event = 'LOST';\n    connection_minutes = parseConnectionMinutesFromSubject(subject);\n  } else if (event_type === 'CONNECTION_RESTORED') {\n    connection_event = 'RESTORED';\n    connection_minutes = parseConnectionMinutesFromSubject(subject);\n  }\n\n   const hash_input = [\n    unit_code || '',\n    event_time || '',\n    event_type || '',\n    geofence_name || '',\n    speed_kmh ?? '',\n    fuel_liters ?? '',\n    fuel_level_liters ?? '',\n    odometer_km ?? '',\n    connection_event ?? '',\n    connection_minutes ?? '',\n    subject || '',\n    email_message_id || ''\n  ].join('|');\n\n  const source_hash = buildSourceHash(hash_input);\n\n  return {\n    unit_code,\n    event_time,\n    event_type,\n    geofence_name,\n    geofence_kind,\n    speed_kmh,\n    fuel_liters,\n    raw_subject: subject || null,\n    raw_body: body || null,\n    hash_input,\n    source_hash,\n    parse_ok: Boolean(event_time && unit_code),\n    data: {\n      address_text,\n      geofence_kind,\n      fuel_level_liters,\n      odometer_km,\n      parsed_speed_kmh: speed_kmh,\n      connection_event,\n      connection_minutes,\n      fuel_liters,\n      email_message_id,\n      imap_uid,\n      email_date: mailDate ? new Date(mailDate).toISOString() : null\n    }\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Adaptador n8n: toma cada correo del trigger IMAP y lo convierte en la fila\n// que espera gps_event. Mismo parser que scripts/backfill_gps_event.js.\n// ---------------------------------------------------------------------------\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const mail = item.json;\n\n  const row = parseGpsEmail({\n    subject: mail.subject || '',\n    textPlain: mail.textPlain || mail.text || '',\n    textHtml: mail.textHtml || mail.html || '',\n    mailDate: mail.date || null,\n    email_message_id: mail.messageId || (mail.metadata || {})['message-id'] || null,\n    imap_uid: mail.uid || null,\n  });\n\n  out.push({\n    json: {\n      ...row,\n      // JSONB va como texto: el nodo de Postgres no serializa objetos.\n      data_json: JSON.stringify(row.data || {}),\n    },\n  });\n}\n\nreturn out;"
      },
      "id": "ParseGpsEmail",
      "name": "Parse GPS Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -480,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "parse-ok",
              "leftValue": "={{ $json.parse_ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "IfParseOk",
      "name": "IF parse_ok",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -240,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO gps_event (\n  unit_code, event_time, type, geofence_name, speed_kmh,\n  raw_subject, raw_body, source_hash, geofence_kind, data\n)\nVALUES ($1, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)\nON CONFLICT (source_hash) DO UPDATE SET\n  unit_code     = EXCLUDED.unit_code,\n  event_time    = EXCLUDED.event_time,\n  type          = EXCLUDED.type,\n  geofence_name = EXCLUDED.geofence_name,\n  speed_kmh     = EXCLUDED.speed_kmh,\n  raw_subject   = EXCLUDED.raw_subject,\n  raw_body      = EXCLUDED.raw_body,\n  geofence_kind = EXCLUDED.geofence_kind,\n  data          = EXCLUDED.data\nRETURNING id;",
        "options": {
          "queryReplacement": "={{ $json.unit_code }}, {{ $json.event_time }}, {{ $json.event_type }}, {{ $json.geofence_name }}, {{ $json.speed_kmh }}, {{ $json.raw_subject }}, {{ $json.raw_body }}, {{ $json.source_hash }}, {{ $json.geofence_kind }}, {{ $json.data_json }}"
        }
      },
      "id": "UpsertGpsEvent",
      "name": "Postgres: Upsert gps_event",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        40,
        -100
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {},
      "id": "ReviewQueue",
      "name": "Sin unidad u hora \u2192 revisar",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        40,
        120
      ]
    }
  ],
  "connections": {
    "Email Trigger (IMAP)": {
      "main": [
        [
          {
            "node": "Parse GPS Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse GPS Email": {
      "main": [
        [
          {
            "node": "IF parse_ok",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF parse_ok": {
      "main": [
        [
          {
            "node": "Postgres: Upsert gps_event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Sin unidad u hora \u2192 revisar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}