diff --git a/src/lib/loadChatComposer.js b/src/lib/loadChatComposer.js new file mode 100644 index 0000000..ac4f78c --- /dev/null +++ b/src/lib/loadChatComposer.js @@ -0,0 +1,28 @@ +// Chat-Baustein des Kundenportals (public/chat-composer.js + .css) zur Laufzeit laden: Eingabezeile mit Anhaengen, +// Kamera und Sprachnachricht sowie die Sprechblasen kommen aus einer Quelle fuer Portal und Ticketsystem. +// Rein (document/window werden injiziert), unter Node testbar. + +const pending = new WeakMap() + +/** -> Promise; je document nur ein Ladevorgang, nach einem Fehler neuer Versuch. */ +export function loadChatComposer({ baseUrl, doc = globalThis.document, win = globalThis.window }) { + if (win.ChatComposer) return Promise.resolve(win.ChatComposer) + if (pending.has(doc)) return pending.get(doc) + const promise = new Promise((resolve, reject) => { + const link = doc.createElement('link') + link.rel = 'stylesheet' + link.href = `${baseUrl}/chat-composer.css` + doc.head.appendChild(link) + const script = doc.createElement('script') + script.src = `${baseUrl}/chat-composer.js` + script.async = true + script.onload = () => (win.ChatComposer ? resolve(win.ChatComposer) : script.onerror()) + script.onerror = () => { + pending.delete(doc) + reject(new Error('Der Chat-Baustein konnte nicht geladen werden.')) + } + doc.head.appendChild(script) + }) + pending.set(doc, promise) + return promise +} diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index c34a9ed..2d1f9c8 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -1,14 +1,18 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' +import { createJwtCache } from './serverpulsAuth' /** * Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer). * Anfrage-Kern (JWT-Cache, Wiederholung bei 401, deutsche Fehlertexte) wie bei den Finanzen: financeRequest.js. */ -const PROJECT_ADMIN_URL = +export const PORTAL_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' -const request = createFinanceRequest({ baseUrl: `${PROJECT_ADMIN_URL}/api/team`, createJWT: () => account.createJWT() }) +const request = createFinanceRequest({ baseUrl: `${PORTAL_URL}/api/team`, createJWT: () => account.createJWT() }) + +// Eigenes JWT fuer Anfragen, die nicht ueber request laufen (Hochladen im Chat-Baustein), 10 Minuten gecacht +const jwtCache = createJwtCache({ createJWT: () => account.createJWT() }) const enc = encodeURIComponent @@ -29,3 +33,29 @@ export async function createPreviewLink({ subdomain, previewUrl }) { const { openUrl } = await request('/preview-link', { method: 'POST', body: { subdomain, previewUrl } }) return openUrl } + +// --- Chat (Messenger, Reiter "Chat", Zahl in der Sidebar) --- + +/** { chats: [{ customerId, customerName, lastText, lastSender, lastAt, unreadFromCustomer }] } */ +export const listChats = () => request('/chats') + +/** { unread } */ +export const getChatUnreadCount = () => request('/chats/unread-count') + +/** { messages }; mit since nur neuere (Abfrage im offenen Gespraech) */ +export const getChatMessages = (customerId, since = '') => + request(`/chats/${enc(customerId)}/messages${since ? `?since=${enc(since)}` : ''}`) + +/** { message, notice } */ +export const sendChatMessage = (customerId, { text, attachmentIds }) => + request(`/chats/${enc(customerId)}/messages`, { method: 'POST', body: { text, attachmentIds } }) + +export const markChatRead = (customerId) => request(`/chats/${enc(customerId)}/read`, { method: 'POST', body: {} }) + +/** Upload-Adresse fuer den Chat-Baustein (POST roh, DELETE /) */ +export const chatUploadUrl = (customerId) => `${PORTAL_URL}/api/team/chats/${enc(customerId)}/attachments` + +/** Header fuer den Chat-Baustein (Option requestHeaders) */ +export async function teamAuthHeaders() { + return { Authorization: `Bearer ${await jwtCache.get()}` } +} diff --git a/tests/unit/loadChatComposer.test.mjs b/tests/unit/loadChatComposer.test.mjs new file mode 100644 index 0000000..de81fc6 --- /dev/null +++ b/tests/unit/loadChatComposer.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +// Chat-Baustein (chat-composer.js/.css) wird zur Laufzeit vom Kundenportal geladen: eine Quelle fuer Portal und WOMS +const { loadChatComposer } = await loadModule('src/lib/loadChatComposer.js') + +function fakeDoc() { + const appended = [] + return { + appended, + head: { appendChild: (node) => { appended.push(node); return node } }, + createElement: (tag) => ({ tag, attrs: {}, setAttribute(k, v) { this.attrs[k] = v } }), + } +} + +test('haengt CSS und Skript einmal an und liefert window.ChatComposer', async () => { + const doc = fakeDoc() + const win = {} + const first = loadChatComposer({ baseUrl: 'https://project.webklar.com', doc, win }) + const second = loadChatComposer({ baseUrl: 'https://project.webklar.com', doc, win }) + assert.equal(doc.appended.length, 2) + const link = doc.appended.find((n) => n.tag === 'link') + const script = doc.appended.find((n) => n.tag === 'script') + assert.equal(link.href || link.attrs.href, 'https://project.webklar.com/chat-composer.css') + assert.equal(link.rel || link.attrs.rel, 'stylesheet') + assert.equal(script.src || script.attrs.src, 'https://project.webklar.com/chat-composer.js') + win.ChatComposer = { create() {} } + script.onload() + assert.equal(await first, win.ChatComposer) + assert.equal(await second, win.ChatComposer) +}) + +test('Skript laedt nicht -> deutsche Meldung, naechster Aufruf versucht es neu', async () => { + const doc = fakeDoc() + const win = {} + const p = loadChatComposer({ baseUrl: 'https://p', doc, win }) + doc.appended.find((n) => n.tag === 'script').onerror() + await assert.rejects(p, { message: 'Der Chat-Baustein konnte nicht geladen werden.' }) + loadChatComposer({ baseUrl: 'https://p', doc, win }) + assert.equal(doc.appended.filter((n) => n.tag === 'script').length, 2) +}) + +test('schon geladen (win.ChatComposer vorhanden) -> nichts anhaengen', async () => { + const doc = fakeDoc() + const win = { ChatComposer: { create() {} } } + assert.equal(await loadChatComposer({ baseUrl: 'https://p', doc, win }), win.ChatComposer) + assert.equal(doc.appended.length, 0) +})