feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.
- hub lib/email-queue.js: GET list (by status, recipients read from each row's
full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.
Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4bf18fdcb
commit
21e2c846bf
28
apps/ops/src/api/emailQueue.js
Normal file
28
apps/ops/src/api/emailQueue.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Email Queue admin API — calls targo-hub /email-queue/* (proxies ERPNext).
|
||||||
|
* Lets ops see + delete outbound emails sitting in ERPNext's queue.
|
||||||
|
*/
|
||||||
|
import { HUB_URL as HUB } from 'src/config/hub'
|
||||||
|
|
||||||
|
export async function listEmailQueue (status = '') {
|
||||||
|
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
|
||||||
|
const r = await fetch(HUB + '/email-queue' + qs)
|
||||||
|
if (!r.ok) throw new Error('Email queue API: ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEmailQueueItem (name) {
|
||||||
|
const r = await fetch(HUB + '/email-queue/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
|
if (!r.ok) throw new Error('Suppression échouée: ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeEmailQueue (status = 'Not Sent') {
|
||||||
|
const r = await fetch(HUB + '/email-queue/purge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('Purge échouée: ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export const navItems = [
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||||
|
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
|
||||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp'
|
||||||
import { navItems as allNavItems } from 'src/config/nav'
|
import { navItems as allNavItems } from 'src/config/nav'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
import { useConversations } from 'src/composables/useConversations'
|
||||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail }
|
||||||
|
|
||||||
const { panelOpen, activeCount: convCount } = useConversations()
|
const { panelOpen, activeCount: convCount } = useConversations()
|
||||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||||
|
|
|
||||||
148
apps/ops/src/pages/EmailQueuePage.vue
Normal file
148
apps/ops/src/pages/EmailQueuePage.vue
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-h6 text-weight-bold">File de courriels (ERPNext)</div>
|
||||||
|
<q-space />
|
||||||
|
<q-select v-model="status" :options="statusOptions" emit-value map-options dense outlined
|
||||||
|
label="Statut" style="width:160px" class="q-mr-sm" @update:model-value="load" />
|
||||||
|
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="q-mr-sm" />
|
||||||
|
<q-btn color="negative" outline dense icon="delete_sweep" label="Purger « Not Sent »"
|
||||||
|
:disable="loading || !notSentCount" @click="confirmPurge" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner dense rounded class="bg-blue-1 text-blue-9 q-mb-md">
|
||||||
|
<template #avatar><q-icon name="mark_email_read" /></template>
|
||||||
|
ERPNext est en <strong>mute_emails</strong> et le scheduler est en pause : <strong>rien ne s'envoie</strong>.
|
||||||
|
Ces entrées sont seulement en file. Tu peux les inspecter et les supprimer en toute sécurité.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div class="row q-gutter-sm q-mb-md">
|
||||||
|
<q-chip v-for="(n, s) in byStatus" :key="s" :color="statusColor(s)" text-color="white" dense>
|
||||||
|
{{ s }} : {{ n }}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip v-if="!rows.length && loaded" color="grey-4" text-color="grey-8" dense>File vide</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
v-if="loaded"
|
||||||
|
:rows="rows" :columns="columns" row-key="name"
|
||||||
|
flat bordered dense class="ops-table"
|
||||||
|
:pagination="{ rowsPerPage: 50, sortBy: 'creation', descending: true }"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<template #body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-chip :color="statusColor(props.value)" text-color="white" dense size="sm" :label="props.value" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-recipients="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div v-for="(r, i) in (props.row.recipients || [])" :key="i" class="text-caption">{{ r }}</div>
|
||||||
|
<span v-if="!(props.row.recipients || []).length" class="text-grey-5">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-reference="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="props.row.reference_doctype" class="text-caption">
|
||||||
|
{{ props.row.reference_doctype }} · {{ props.row.reference_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-grey-5">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-creation="props">
|
||||||
|
<q-td :props="props">{{ fmtDate(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-error="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-icon v-if="props.value" name="error_outline" color="negative" />
|
||||||
|
<q-tooltip v-if="props.value" max-width="480px" class="text-body2">{{ props.value }}</q-tooltip>
|
||||||
|
<span v-else class="text-grey-5">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn dense flat round size="sm" icon="delete" color="negative" @click="confirmDelete(props.row)">
|
||||||
|
<q-tooltip>Supprimer ce courriel de la file</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { listEmailQueue, deleteEmailQueueItem, purgeEmailQueue } from 'src/api/emailQueue'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const status = ref('')
|
||||||
|
const byStatus = ref({})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Tous', value: '' },
|
||||||
|
{ label: 'Not Sent', value: 'Not Sent' },
|
||||||
|
{ label: 'Sending', value: 'Sending' },
|
||||||
|
{ label: 'Sent', value: 'Sent' },
|
||||||
|
{ label: 'Error', value: 'Error' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
|
||||||
|
{ name: 'recipients', label: 'Destinataire(s)', field: 'recipients', align: 'left' },
|
||||||
|
{ name: 'reference', label: 'Référence', field: 'reference_doctype', align: 'left' },
|
||||||
|
{ name: 'creation', label: 'Créé', field: 'creation', align: 'left', sortable: true },
|
||||||
|
{ name: 'error', label: 'Erreur', field: 'error', align: 'center' },
|
||||||
|
{ name: 'actions', label: '', field: 'name', align: 'center' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const notSentCount = computed(() => byStatus.value['Not Sent'] || 0)
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
return { 'Not Sent': 'orange-8', Sending: 'blue-6', Sent: 'green-7', Error: 'negative', Expired: 'grey-6' }[s] || 'grey-7'
|
||||||
|
}
|
||||||
|
function fmtDate (iso) {
|
||||||
|
return iso ? new Date(iso.replace(' ', 'T')).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await listEmailQueue(status.value)
|
||||||
|
rows.value = data.rows || []
|
||||||
|
byStatus.value = data.by_status || {}
|
||||||
|
loaded.value = true
|
||||||
|
} catch (e) {
|
||||||
|
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete (row) {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Supprimer',
|
||||||
|
message: `Supprimer ce courriel de la file (${(row.recipients || []).join(', ') || row.name}) ?`,
|
||||||
|
cancel: true, persistent: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
try { await deleteEmailQueueItem(row.name); $q.notify({ type: 'positive', message: 'Supprimé' }); load() }
|
||||||
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPurge () {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Purger',
|
||||||
|
message: `Supprimer les ${notSentCount.value} courriel(s) « Not Sent » de la file ?`,
|
||||||
|
cancel: true, persistent: true,
|
||||||
|
}).onOk(async () => {
|
||||||
|
try { const r = await purgeEmailQueue('Not Sent'); $q.notify({ type: 'positive', message: `${r.deleted} supprimé(s)` }); load() }
|
||||||
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
@ -35,6 +35,7 @@ const routes = [
|
||||||
{ path: 'rapports/internet-cher', component: () => import('src/pages/ReportInternetCherPage.vue') },
|
{ path: 'rapports/internet-cher', component: () => import('src/pages/ReportInternetCherPage.vue') },
|
||||||
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
||||||
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
|
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
|
||||||
|
{ path: 'email-queue', component: () => import('src/pages/EmailQueuePage.vue') },
|
||||||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||||
|
|
|
||||||
74
services/targo-hub/lib/email-queue.js
Normal file
74
services/targo-hub/lib/email-queue.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* email-queue.js — admin view of ERPNext's outbound Email Queue.
|
||||||
|
*
|
||||||
|
* During the migration, ERPNext email is globally muted (mute_emails=1) and the
|
||||||
|
* scheduler is paused, so nothing flushes. This gives ops a window to SEE what
|
||||||
|
* is queued (sender, recipients, reference doc, status, error) and DELETE/PURGE
|
||||||
|
* stale entries — without opening the ERPNext desk.
|
||||||
|
*
|
||||||
|
* Backed by the ERPNext REST API (same token the hub already uses).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /email-queue?status=Not%20Sent&limit=200 → { rows, count }
|
||||||
|
* DELETE /email-queue/:name → { ok, deleted }
|
||||||
|
* POST /email-queue/purge { status } → { ok, deleted }
|
||||||
|
*/
|
||||||
|
const { json, parseBody, erpFetch } = require('./helpers')
|
||||||
|
|
||||||
|
async function listQueue (status, limit) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fields: JSON.stringify(['name', 'status', 'sender', 'reference_doctype', 'reference_name', 'creation', 'error', 'retry']),
|
||||||
|
order_by: 'creation desc',
|
||||||
|
limit_page_length: String(Math.min(500, limit || 200)),
|
||||||
|
})
|
||||||
|
if (status) params.set('filters', JSON.stringify([['status', '=', status]]))
|
||||||
|
const res = await erpFetch('/api/resource/Email Queue?' + params)
|
||||||
|
if (res.status !== 200) throw new Error('ERP list failed: ' + res.status)
|
||||||
|
const rows = res.data?.data || []
|
||||||
|
// ERPNext ignores `fields` when listing a child doctype via REST (returns
|
||||||
|
// only `name`), so read each row's recipients from its full doc instead.
|
||||||
|
// Capped to keep the call snappy on a large queue.
|
||||||
|
const CAP = 60
|
||||||
|
await Promise.all(rows.slice(0, CAP).map(async (r) => {
|
||||||
|
try {
|
||||||
|
const d = await erpFetch('/api/resource/Email Queue/' + encodeURIComponent(r.name))
|
||||||
|
r.recipients = (d.data?.data?.recipients || []).map(x => x.recipient).filter(Boolean)
|
||||||
|
} catch { r.recipients = [] }
|
||||||
|
}))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOne (name) {
|
||||||
|
const r = await erpFetch('/api/resource/Email Queue/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
|
return r.status === 202 || r.status === 200
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle (req, res, method, path, url) {
|
||||||
|
// GET /email-queue
|
||||||
|
if (path === '/email-queue' && method === 'GET') {
|
||||||
|
const rows = await listQueue(url.searchParams.get('status') || '', parseInt(url.searchParams.get('limit') || '200', 10))
|
||||||
|
const byStatus = {}
|
||||||
|
for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1
|
||||||
|
return json(res, 200, { rows, count: rows.length, by_status: byStatus })
|
||||||
|
}
|
||||||
|
// POST /email-queue/purge { status } — checked before the generic :name route
|
||||||
|
if (path === '/email-queue/purge' && method === 'POST') {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const status = body.status || 'Not Sent'
|
||||||
|
const rows = await listQueue(status, 500)
|
||||||
|
let deleted = 0
|
||||||
|
for (const r of rows) { if (await deleteOne(r.name)) deleted++ }
|
||||||
|
return json(res, 200, { ok: true, deleted, status })
|
||||||
|
}
|
||||||
|
// DELETE /email-queue/:name
|
||||||
|
const m = path.match(/^\/email-queue\/(.+)$/)
|
||||||
|
if (m && method === 'DELETE') {
|
||||||
|
const name = decodeURIComponent(m[1])
|
||||||
|
if (await deleteOne(name)) return json(res, 200, { ok: true, deleted: name })
|
||||||
|
return json(res, 500, { error: 'delete failed', name })
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle }
|
||||||
|
|
@ -124,6 +124,8 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
|
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
|
||||||
// Per-address competitor/provider lookup via Québec IHV open data (ADR+FRN).
|
// Per-address competitor/provider lookup via Québec IHV open data (ADR+FRN).
|
||||||
if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path)
|
if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path)
|
||||||
|
// Admin view of ERPNext outbound Email Queue (view/delete/purge).
|
||||||
|
if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
||||||
// Gift redirect wrapper — short public URLs in campaign emails that
|
// Gift redirect wrapper — short public URLs in campaign emails that
|
||||||
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user