From 03ddb7421e68ea750324a46d09653bc9c14812a5 Mon Sep 17 00:00:00 2001 From: Andrew Balmos Date: Fri, 12 Jan 2024 00:52:10 -0500 Subject: [PATCH] feat(side-convo): Generate email side converstation PDFs Signed-off-by: Andrew Balmos --- src/index.ts | 10 +- src/zd/pdf.ts | 69 +++++- src/zd/zendesk.ts | 87 ++++++- template/src/app.html | 27 ++- template/src/routes/+layout.svelte | 2 +- template/src/routes/+layout.ts | 2 +- template/src/routes/side/+page.svelte | 225 ++++++++++++++++++ template/src/routes/{ => ticket}/+page.svelte | 30 +-- template/src/routes/utils.ts | 29 +++ template/svelte.config.js | 2 +- 10 files changed, 429 insertions(+), 54 deletions(-) create mode 100644 template/src/routes/side/+page.svelte rename template/src/routes/{ => ticket}/+page.svelte (93%) create mode 100644 template/src/routes/utils.ts diff --git a/src/index.ts b/src/index.ts index b4df0fe..d226407 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ import { searchTickets, Ticket, } from './zd/zendesk.js'; -import { generatePdf } from './zd/pdf.js'; +import { generatePdf, generateSideConverstationPdf } from './zd/pdf.js'; import { writeFileSync } from 'node:fs'; // Stuff from config const { token, domain } = config.get('oada'); @@ -157,6 +157,14 @@ export async function handleTicket( const pdf = await generatePdf(archive); + let i = 1; + for (let sideConv of archive.sideConversations) { + const pdf = await generateSideConverstationPdf(archive, sideConv); + info(`Writing output side PDF ${i}`); + writeFileSync(`./side-${i}.pdf`, pdf); + i++; + } + // FIXME: Remove info('Writing output PDF'); writeFileSync('./output.pdf', pdf); diff --git a/src/zd/pdf.ts b/src/zd/pdf.ts index d99ab59..77d3cad 100644 --- a/src/zd/pdf.ts +++ b/src/zd/pdf.ts @@ -21,7 +21,11 @@ import { createServer } from 'http'; import { join, extname } from 'path'; import { launch } from 'puppeteer'; import debug from 'debug'; -import { TicketArchive } from './zendesk.js'; +import { + SideConversation, + SideConversationWithEvents, + TicketArchive, +} from './zendesk.js'; const info = debug('zendesk-sync:pdf:info'); const warn = debug('zendesk-sync:pdf:warn'); @@ -51,7 +55,7 @@ const prepareFile = async (url: string) => { () => false, ); const found = !pathTraversal && exists; - const streamPath = found ? filePath : STATIC_PATH + '/404.html'; + const streamPath = found ? filePath : STATIC_PATH + '/index.html'; const ext = extname(streamPath).substring(1).toLowerCase(); const stream = createReadStream(streamPath); return { found, ext, stream }; @@ -62,7 +66,7 @@ let address = new Promise((resolve, reject) => { const server = createServer(async (req, res) => { if (req.url) { const file = await prepareFile(req.url); - const statusCode = file.found ? 200 : 404; + const statusCode = 200; // We always fallback to index.html const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default; res.writeHead(statusCode, { 'Content-Type': mimeType }); file.stream.pipe(res); @@ -119,7 +123,64 @@ export async function generatePdf(archive: TicketArchive): Promise { } }); - await page.goto(`http://${await address}`, { + await page.goto(`http://${await address}/ticket`, { + waitUntil: ['load', 'networkidle0'], + }); + + const pdf = await page.pdf({ + format: 'letter', + margin: { top: '20px', left: '20px', right: '20px', bottom: '20px' }, + printBackground: true, + }); + + await browser.close(); + + return pdf; +} + +// TODO: Should we pass in sideConv, or just an index, or should this function just return an array of PDFs, or... +export async function generateSideConverstationPdf( + archive: TicketArchive, + sideConv: SideConversationWithEvents, +): Promise { + const browser = await launch({ + headless: 'new', + args: [ + '--disable-extensions', + '--no-sandbox', + '--disable-gpu', + '--disable-web-security', + ], + dumpio: true, + }); + + let page = (await browser.newPage()) + .on('load', trace) + .on('error', error) + .on('console', (message) => + info(`${message.type().substring(0, 3).toUpperCase()} ${message.text()}`), + ) + .on('pageerror', error) + .on('requestfailed', (request) => + warn(`${request.failure()?.errorText} ${request.url()}`), + ); + + await page.setRequestInterception(true); + + page.on('request', (request) => { + if (request.url() === 'http://127.0.0.1/_data') { + console.log(sideConv); + request.respond({ + contentType: 'application/json', + headers: { 'Access-Control-Allow-Origin': '' }, + body: JSON.stringify({ ticket: archive, ...sideConv }), + }); + } else { + request.continue(); + } + }); + + await page.goto(`http://${await address}/side`, { waitUntil: ['load', 'networkidle0'], }); diff --git a/src/zd/zendesk.ts b/src/zd/zendesk.ts index 094457c..03a03ec 100644 --- a/src/zd/zendesk.ts +++ b/src/zd/zendesk.ts @@ -72,7 +72,11 @@ export async function getTicketArchive( ///////////////// let ids = comments .map((c) => [c.author_id, ...(c.via.source.to.email_ccs || [])]) - .concat(sideConversations.map((s) => s.participants.map((p) => p.user_id))) + .concat( + sideConversations.map((s) => + s.side_conversation.participants.map((p) => p.user_id), + ), + ) .flat() .filter((user, index, array) => array.indexOf(user) === index); @@ -201,8 +205,8 @@ export async function getCommentsFromTicket( export async function getSideConversationsFromTicket( ticket: Ticket, -): Promise> { - let sideConversations: Array = []; +): Promise> { + let sideConvos: Array = []; let r = await throttle( async () => @@ -213,10 +217,10 @@ export async function getSideConversationsFromTicket( username, password, }, - }) as Promise<{ data: SideConverstationResponse }>, + }) as Promise<{ data: SideConversationResponse }>, )(); - sideConversations.push(...r.data.side_conversations); + sideConvos.push(...r.data.side_conversations); while (r.data.next_page) { r = await throttle(async () => @@ -230,10 +234,27 @@ export async function getSideConversationsFromTicket( }), )(); - sideConversations.push(...r.data.side_conversations); + sideConvos.push(...r.data.side_conversations); } - return sideConversations; + // Replace all the side conversation objects with on where `events` is side loaded expanded + return Promise.all( + sideConvos.map(async (sideConv) => { + let r = await throttle( + async () => + axios({ + method: 'get', + url: `${ZD_DOMAIN}/api/v2/tickets/${ticket.id}/side_conversations/${sideConv.id}?include=events`, + auth: { + username, + password, + }, + }) as Promise<{ data: SideConversationWithEvents }>, + )(); + + return r.data; + }), + ); } export async function getOrgs( @@ -419,7 +440,7 @@ export interface TicketArchive { orgs: Record; groups: Record; ticketFields: Record; - sideConversations: Array; + sideConversations: Array; } export interface Org { @@ -604,13 +625,61 @@ export interface SideConversation { }; } -interface SideConverstationResponse { +interface SideConversationResponse { side_conversations: Array; next_page: string | null; previous_page: string | null; count: number; } +export interface SideConversationWithEvents { + side_conversation: SideConversation; + events?: Array<{ + id: string; + side_conversation_id: string; + actor: { + user_id: number; + name: string; + email: string; + }; + type: string; + via: string; + created_at: string; + message: { + subject: string; + preview_text: string; + from: { + user_id: number; + name: string; + email: string; + }; + to: Array<{ + user_id: number; + name: string; + email: string; + }>; + body: string; + html_body: string; + external_ids: { + ticketAuditId: string; + outboundEmail?: string; + }; + attachments: Array<{ + id: string; + file_name: string; + size: number; + content_url: string; + content_type: string; + width: number; + height: number; + inline: boolean; + }>; + updates: {}; + ticket_id: number; + }; + }>; +} + export interface TicketField { id: number; position: number; diff --git a/template/src/app.html b/template/src/app.html index 2835105..239b6cb 100644 --- a/template/src/app.html +++ b/template/src/app.html @@ -1,18 +1,19 @@ - + + + + - - - + - - - - %sveltekit.head% - - - -
%sveltekit.body%
- + + %sveltekit.head% + + +
%sveltekit.body%
+ \ No newline at end of file diff --git a/template/src/routes/+layout.svelte b/template/src/routes/+layout.svelte index 69be899..89fce52 100644 --- a/template/src/routes/+layout.svelte +++ b/template/src/routes/+layout.svelte @@ -1,5 +1,5 @@
diff --git a/template/src/routes/+layout.ts b/template/src/routes/+layout.ts index ceccaaf..d2c0be2 100644 --- a/template/src/routes/+layout.ts +++ b/template/src/routes/+layout.ts @@ -1,2 +1,2 @@ export const prerender = true; -export const ssr = false; +export const ssr = false; \ No newline at end of file diff --git a/template/src/routes/side/+page.svelte b/template/src/routes/side/+page.svelte new file mode 100644 index 0000000..dbf7ff1 --- /dev/null +++ b/template/src/routes/side/+page.svelte @@ -0,0 +1,225 @@ + + +{#await p then { ticket: { ticket, users, orgs, ticketFields }, side_conversation: sideConv, events }} + + +

+ {sideConv.subject || 'No Subject'} +

+

{events?.length || '0'} messages

+ +
+
+
Status
+
+ {sideConv.state || 'Unknown'} +
+
+ +
+
Parent Ticket
+
+
{ticket.subject}
+
#{sideConv.ticket_id}
+
+
+ +
+
Opened
+
+ {sideConv?.created_at && formatYMD2(sideConv.created_at)} +
+
+ +
+
Last updated
+
+ {sideConv.updated_at && formatYMD2(sideConv.updated_at)} +
+
+
+ +

+ +

+
Tags
+ {#if ticket?.tags.length > 0} + {#each ticket?.tags || [] as tag} +
{tag}
+ {/each} + {:else} +
+ None +
+ {/if} +
+ +
+
Request SAP ID
+
+ {#if ticket?.organization_id} + {orgs[ticket?.organization_id]?.organization_fields?.sap_id || '--'} + {:else} + None + {/if} +
+
+ +
+
Custom Fields
+ + + + + + + + + {#each ticket?.custom_fields as field} + + + + + {/each} + +
FieldValue
{ticketFields[field?.id]?.title || ''} + {(ticketFields[field?.id]?.custom_field_options || []).find( + (f) => f.value == field.value + )?.name || '--'} +
+
+ +
+ {#each events || [] as event} +
+
+
+
+ {#if users[event.actor?.user_id]?.photo} +
+
+ +
+
+ {:else} +
+
+ + {(users[event.actor?.user_id]?.name || '').substr(0, 1)} + +
+
+ {/if} +
+ +
+
+ + {users[event.message?.from?.user_id]?.name || 'Unknown'} + {#if users[event.message?.from?.author_id]?.email} + + <{users[event.message?.from?.author_id]?.email}> + + {/if} + + + + {event.created_at && formatLongDate2(event.created_at)} + +
+ +
    + {#each event.message?.to as user, i} +
  • + To: "{users[user.user_id]?.name}" + {#if users[user.user_id]?.email} + + <{users[user.user_id]?.email}> + {#if i != event.message.to.length - 1},{/if} + + {/if} +
  • + {/each} +
+ +
+