ticket: aller-retour courriel (envoi + réponse revient au fil)
Hub (conversation.js) : getOrCreateConvForIssue (lie une Issue à une conv email) + emailTicket (envoie dans un fil Gmail, sujet [Ticket #ISS-…] + lien direct, journalise Sent Communication). Route POST /conversations/ticket-email (gaté Authentik). La réponse du client re-thread par le tag sujet (TICKET_REF_RX) OU le threadId → logToLinkedTicket (déjà en place) → Communication Received sur l'Issue → visible au panneau. notifyCustomer mémorise désormais conv.threadId au 1er envoi. UI (IssueDetail) : bouton « Envoyer par courriel » + dialogue (À/Cc/message), « Répondre » scindé en « Note interne » (Communication) vs courriel. Lien direct = URL du ticket. Testé : envoi réel à louis@targo.ca sur ISS-0000250013 (channel:email, Sent Communication OK). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cb16167f72
commit
417ef08f58
|
|
@ -249,17 +249,44 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="q-mt-md reply-box">
|
<div class="q-mt-md reply-box">
|
||||||
<div class="info-block-title">Repondre</div>
|
<div class="info-block-title">Répondre</div>
|
||||||
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
|
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
|
||||||
placeholder="Ecrire une reponse..."
|
placeholder="Écrire une réponse ou une note…"
|
||||||
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
||||||
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
||||||
<div class="row justify-end q-mt-xs">
|
<div class="row justify-end q-gutter-sm q-mt-xs">
|
||||||
<q-btn unelevated dense size="sm" label="Envoyer" color="primary" icon="send"
|
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
|
||||||
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
|
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
|
||||||
|
<q-tooltip>Ajoute une note au fil — non envoyée au client</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
|
||||||
|
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Aller-retour courriel : envoie le ticket, la réponse revient au fil du ticket -->
|
||||||
|
<q-dialog v-model="showEmailDialog">
|
||||||
|
<q-card style="min-width:min(92vw,460px)">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<q-icon name="mail" color="primary" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Envoyer par courriel</div>
|
||||||
|
<q-space /><q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<q-input v-model="emailTo" dense outlined label="À (courriel)" type="email" autofocus />
|
||||||
|
<q-input v-model="emailCc" dense outlined label="Cc (optionnel)" />
|
||||||
|
<q-input v-model="emailMessage" dense outlined type="textarea" autogrow label="Message" :input-style="{ minHeight: '90px' }" />
|
||||||
|
<div class="text-caption text-grey-6"><q-icon name="info" size="13px" /> Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn unelevated color="primary" icon="send" label="Envoyer" :loading="sendingEmail"
|
||||||
|
:disable="!emailTo || !/.+@.+\..+/.test(emailTo)" @click="sendTicketEmail" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Create single Dispatch Job dialog -->
|
<!-- Create single Dispatch Job dialog -->
|
||||||
<UnifiedCreateModal
|
<UnifiedCreateModal
|
||||||
v-model="showCreateDialog"
|
v-model="showCreateDialog"
|
||||||
|
|
@ -287,6 +314,7 @@ import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
|
||||||
import { authFetch } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
|
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||||
|
|
@ -725,6 +753,48 @@ async function sendReply () {
|
||||||
sendingReply.value = false
|
sendingReply.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Envoi par courriel (aller-retour : la réponse revient au ticket) ──
|
||||||
|
|
||||||
|
const showEmailDialog = ref(false)
|
||||||
|
const emailTo = ref('')
|
||||||
|
const emailCc = ref('')
|
||||||
|
const emailMessage = ref('')
|
||||||
|
const sendingEmail = ref(false)
|
||||||
|
|
||||||
|
function openEmailDialog () {
|
||||||
|
emailTo.value = (props.doc?.raised_by && /.+@.+\..+/.test(props.doc.raised_by)) ? props.doc.raised_by : ''
|
||||||
|
emailCc.value = ''
|
||||||
|
emailMessage.value = replyContent.value?.trim() || ''
|
||||||
|
showEmailDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTicketEmail () {
|
||||||
|
if (!emailTo.value || sendingEmail.value) return
|
||||||
|
sendingEmail.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
|
||||||
|
message: emailMessage.value.trim(), link: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok) {
|
||||||
|
Notify.create({ type: 'positive', message: 'Courriel envoyé — la réponse reviendra à ce ticket', timeout: 2800 })
|
||||||
|
showEmailDialog.value = false
|
||||||
|
replyContent.value = ''
|
||||||
|
emit('reply-sent', props.docName)
|
||||||
|
} else {
|
||||||
|
Notify.create({ type: 'negative', message: d.error || 'Échec de l’envoi', timeout: 3500 })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
|
||||||
|
} finally {
|
||||||
|
sendingEmail.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,23 @@ function getOrCreateConvForLegacyTicket (legacyTicketId, { customer, customerNam
|
||||||
return conv
|
return conv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante ERPNext Issue : conversation courriel liée à un ticket ERPNext (Issue name = ISS-…).
|
||||||
|
// Clé = conv.linkedTickets[].name. Idempotent. La réponse du client re-thread ici (subject [Ticket #ISS-…] → TICKET_REF_RX).
|
||||||
|
function getOrCreateConvForIssue (issueName, { customer, customerName, email, subject } = {}) {
|
||||||
|
const n = String(issueName || '').trim(); if (!n) return null
|
||||||
|
for (const c of conversations.values()) {
|
||||||
|
if ((c.linkedTickets || []).some(t => t && String(t.name) === n)) { if (email && !c.email) c.email = email; if (!c.channel) c.channel = 'email'; return c }
|
||||||
|
}
|
||||||
|
const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || ('Ticket ' + n), agentEmail: null, subject: subject || ('Ticket ' + n) })
|
||||||
|
conv.channel = 'email'
|
||||||
|
conv.email = email || null
|
||||||
|
conv.linkedTickets = [{ name: n, subject: subject || '', ts: new Date().toISOString() }]
|
||||||
|
saveToDisk()
|
||||||
|
require('./erp').update('Issue', n, { conversation_token: conv.token }).catch(() => {}) // back-link best-effort
|
||||||
|
log(`Conv ${conv.token} liée au ticket ERPNext ${n}`)
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
// Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job.
|
// Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job.
|
||||||
// Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket.
|
// Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket.
|
||||||
function findConvByJob (jobName) {
|
function findConvByJob (jobName) {
|
||||||
|
|
@ -269,6 +286,7 @@ async function notifyCustomer (conv, message) {
|
||||||
const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
|
const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
|
||||||
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) })
|
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) })
|
||||||
if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
|
if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
|
||||||
|
if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…])
|
||||||
log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
|
log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
|
||||||
return 'email'
|
return 'email'
|
||||||
} catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' }
|
} catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' }
|
||||||
|
|
@ -761,6 +779,32 @@ async function logToLinkedTicket (conv, msg, subject) {
|
||||||
} catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) }
|
} catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
|
||||||
|
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
|
||||||
|
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
|
||||||
|
async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
|
||||||
|
const iss = String(issue || '').trim()
|
||||||
|
const dest = String(to || '').trim()
|
||||||
|
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
|
||||||
|
let subject = 'Ticket ' + iss, customer = null
|
||||||
|
try {
|
||||||
|
const rows = await require('./erp').list('Issue', { filters: [['name', '=', iss]], fields: ['subject', 'customer'], limit: 1 })
|
||||||
|
if (rows && rows[0]) { subject = rows[0].subject || subject; customer = rows[0].customer || null }
|
||||||
|
} catch (e) { log('emailTicket lookup ' + iss + ': ' + e.message) }
|
||||||
|
const conv = getOrCreateConvForIssue(iss, { customer, email: dest, subject })
|
||||||
|
if (!conv) return { ok: false, error: 'conversation non créée' }
|
||||||
|
conv.email = dest; conv.channel = 'email'; if (!conv.lastSubject) conv.lastSubject = subject
|
||||||
|
const text = String(message || '').trim()
|
||||||
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
let html = esc(text).replace(/\n/g, '<br>')
|
||||||
|
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
|
||||||
|
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', toEmail: dest, ccRaw: cc || '' })
|
||||||
|
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
|
||||||
|
await logToLinkedTicket(conv, msg, subject).catch(() => {})
|
||||||
|
saveToDisk()
|
||||||
|
return { ok: chan === 'email', channel: chan, conv: conv.token, issue: iss }
|
||||||
|
}
|
||||||
|
|
||||||
// P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket
|
// P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket
|
||||||
// legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro.
|
// legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro.
|
||||||
// Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un
|
// Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un
|
||||||
|
|
@ -1261,6 +1305,11 @@ async function handle (req, res, method, p, url) {
|
||||||
try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
|
||||||
|
if (p === '/conversations/ticket-email' && method === 'POST') {
|
||||||
|
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
||||||
|
}
|
||||||
|
|
||||||
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
|
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
|
||||||
if (p === '/conversations/email-ingest' && method === 'POST') {
|
if (p === '/conversations/email-ingest' && method === 'POST') {
|
||||||
const tok = req.headers['x-mail-token'] || ''
|
const tok = req.headers['x-mail-token'] || ''
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user