keystone-design-bootstrap 1.0.104 → 1.0.105-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +17 -1
- package/dist/index.js +46 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/components/ChatWidget.tsx +66 -10
- package/src/lib/chat-backend.ts +36 -0
- package/src/next/layouts/root-layout.tsx +5 -0
- package/src/next/routes/chat.ts +215 -18
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import { cx } from '../../utils/cx';
|
|
|
7
7
|
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
8
|
import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
|
|
9
9
|
import { error as logError } from '../../tracking/logging';
|
|
10
|
+
import { RAILS_BACKEND, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
|
|
10
11
|
|
|
11
12
|
interface Message {
|
|
12
13
|
id: string;
|
|
@@ -25,6 +26,18 @@ interface TeamMember {
|
|
|
25
26
|
|
|
26
27
|
interface ChatWidgetProps {
|
|
27
28
|
position?: 'bottom-right' | 'bottom-left';
|
|
29
|
+
/**
|
|
30
|
+
* Which backend the site's `/api/chat` proxy talks to. The two backends have
|
|
31
|
+
* architecturally different reply delivery: 'rails' (keystone-mono-proto)
|
|
32
|
+
* pushes replies over ActionCable realtime; 'sor' (sor-service) has no
|
|
33
|
+
* realtime channel and the widget polls for the reply instead. The paths are
|
|
34
|
+
* mutually exclusive — a 'sor' widget never probes/subscribes realtime, a
|
|
35
|
+
* 'rails' widget never polls.
|
|
36
|
+
*
|
|
37
|
+
* TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
|
|
38
|
+
* backend; flip the default to 'sor' once the Rails backend is scrapped.
|
|
39
|
+
*/
|
|
40
|
+
chatBackend?: ChatBackend;
|
|
28
41
|
primaryColor?: string;
|
|
29
42
|
/**
|
|
30
43
|
* Anonymous session identifier. Used when the visitor is not authenticated.
|
|
@@ -66,6 +79,7 @@ const formatTime = (isoString: string) => {
|
|
|
66
79
|
export function ChatWidget({
|
|
67
80
|
position = 'bottom-right',
|
|
68
81
|
primaryColor,
|
|
82
|
+
chatBackend = RAILS_BACKEND,
|
|
69
83
|
sessionId: providedSessionId,
|
|
70
84
|
contactId,
|
|
71
85
|
displayName: providedDisplayName,
|
|
@@ -146,7 +160,10 @@ export function ChatWidget({
|
|
|
146
160
|
);
|
|
147
161
|
}, []);
|
|
148
162
|
|
|
163
|
+
const isSorBackend = chatBackend === SOR_BACKEND;
|
|
164
|
+
|
|
149
165
|
const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
|
|
166
|
+
if (isSorBackend) return null; // sor has no realtime channel — never probe
|
|
150
167
|
if (!contactId && !sessionId) return null;
|
|
151
168
|
const query = contactId
|
|
152
169
|
? `contact_id=${encodeURIComponent(contactId)}`
|
|
@@ -159,7 +176,7 @@ export function ChatWidget({
|
|
|
159
176
|
const cableUrl = result?.data?.cable_url as string | undefined;
|
|
160
177
|
if (!token || !contactIdForStream || !cableUrl) return null;
|
|
161
178
|
return { token, contact_id: contactIdForStream, cable_url: cableUrl };
|
|
162
|
-
}, [contactId, sessionId]);
|
|
179
|
+
}, [contactId, isSorBackend, sessionId]);
|
|
163
180
|
|
|
164
181
|
const {
|
|
165
182
|
beginReplyWait,
|
|
@@ -179,13 +196,48 @@ export function ChatWidget({
|
|
|
179
196
|
|
|
180
197
|
const hasPersistedMessages = messages.some((message) => !String(message.id).startsWith('temp_'));
|
|
181
198
|
|
|
199
|
+
// rails reply delivery: ActionCable realtime subscription.
|
|
182
200
|
useEffect(() => {
|
|
201
|
+
if (isSorBackend) return;
|
|
183
202
|
if (!isOpen || (!contactId && !sessionId)) return;
|
|
184
203
|
// Anonymous sessions do not have a contact row until the first message is sent.
|
|
185
204
|
// Ignore optimistic temp rows to avoid pre-contact token probes (404 noise).
|
|
186
205
|
if (!contactId && !hasPersistedMessages) return;
|
|
187
206
|
ensureRealtimeSubscription();
|
|
188
|
-
}, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, sessionId]);
|
|
207
|
+
}, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, isSorBackend, sessionId]);
|
|
208
|
+
|
|
209
|
+
// sor reply delivery: no realtime channel exists, so while waiting for the
|
|
210
|
+
// agent reply, poll the thread and resolve as soon as an agent message with
|
|
211
|
+
// a body lands (or after a timeout). Never runs for the rails backend.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!isSorBackend || !waitingForReply) return;
|
|
214
|
+
let cancelled = false;
|
|
215
|
+
let inFlight = false; // don't stack requests when a poll outlives the interval
|
|
216
|
+
let attempts = 0;
|
|
217
|
+
const maxAttempts = 40; // ~60s at 1.5s
|
|
218
|
+
const interval = setInterval(async () => {
|
|
219
|
+
if (inFlight) return;
|
|
220
|
+
inFlight = true;
|
|
221
|
+
attempts += 1;
|
|
222
|
+
try {
|
|
223
|
+
const msgs = await loadMessages();
|
|
224
|
+
if (cancelled) return;
|
|
225
|
+
if (hasAgentReplyWithBody(msgs) || attempts >= maxAttempts) {
|
|
226
|
+
clearInterval(interval);
|
|
227
|
+
setWaitingForReply(false);
|
|
228
|
+
setIsLoading(false);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// transient — keep polling until maxAttempts
|
|
232
|
+
} finally {
|
|
233
|
+
inFlight = false;
|
|
234
|
+
}
|
|
235
|
+
}, 1500);
|
|
236
|
+
return () => {
|
|
237
|
+
cancelled = true;
|
|
238
|
+
clearInterval(interval);
|
|
239
|
+
};
|
|
240
|
+
}, [isSorBackend, waitingForReply, loadMessages, hasAgentReplyWithBody]);
|
|
189
241
|
|
|
190
242
|
const sendMessage = async () => {
|
|
191
243
|
if (!inputValue.trim() || (!contactId && !sessionId)) return;
|
|
@@ -220,15 +272,19 @@ export function ChatWidget({
|
|
|
220
272
|
captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
|
|
221
273
|
|
|
222
274
|
if (result.data?.job_id) {
|
|
275
|
+
// setWaitingForReply arms the sor polling effect; rails additionally
|
|
276
|
+
// subscribes to the realtime channel for push delivery.
|
|
223
277
|
setWaitingForReply(true);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
278
|
+
if (!isSorBackend) {
|
|
279
|
+
const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
|
|
280
|
+
? {
|
|
281
|
+
token: result.data.realtime_token,
|
|
282
|
+
contact_id: result.data.contact_id,
|
|
283
|
+
cable_url: result.data.cable_url,
|
|
284
|
+
}
|
|
285
|
+
: null;
|
|
286
|
+
beginReplyWait({ realtimeData: realtimeFromSend });
|
|
287
|
+
}
|
|
232
288
|
} else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
|
|
233
289
|
setIsLoading(false);
|
|
234
290
|
setWaitingForReply(false);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which chat backend the site's /api/chat proxy targets.
|
|
3
|
+
*
|
|
4
|
+
* The two backends have architecturally different webchat contracts:
|
|
5
|
+
* - 'rails' (keystone-mono-proto): /public/messages + ActionCable realtime.
|
|
6
|
+
* - 'sor' (sor-service): /public/chat/* + widget long-polling.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. CHAT_BACKEND env ('rails' | 'sor') — explicit override; needed for
|
|
10
|
+
* custom domains or local dev, where the API_URL hostname carries no signal.
|
|
11
|
+
* 2. Derived from API_URL: a hostname containing a `sor` label
|
|
12
|
+
* (sor.localkeystone.com, sor.dev.localkeystone.com, …) ⇒ 'sor'.
|
|
13
|
+
* 3. Default 'rails'.
|
|
14
|
+
*
|
|
15
|
+
* TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
|
|
16
|
+
* backend; flip the default to 'sor' once the Rails backend is scrapped.
|
|
17
|
+
*/
|
|
18
|
+
import { serverError } from './server-log';
|
|
19
|
+
|
|
20
|
+
export const RAILS_BACKEND = 'rails' as const;
|
|
21
|
+
export const SOR_BACKEND = 'sor' as const;
|
|
22
|
+
export type ChatBackend = typeof RAILS_BACKEND | typeof SOR_BACKEND;
|
|
23
|
+
|
|
24
|
+
export function resolveChatBackend(): ChatBackend {
|
|
25
|
+
const explicit = process.env.CHAT_BACKEND;
|
|
26
|
+
if (explicit === SOR_BACKEND || explicit === RAILS_BACKEND) return explicit;
|
|
27
|
+
|
|
28
|
+
const apiUrl = process.env.API_URL || '';
|
|
29
|
+
try {
|
|
30
|
+
const host = new URL(apiUrl).hostname;
|
|
31
|
+
if (host.split('.').includes(SOR_BACKEND)) return SOR_BACKEND;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
serverError('CHAT_BACKEND_API_URL_PARSE_ERROR', error, { apiUrl });
|
|
34
|
+
}
|
|
35
|
+
return RAILS_BACKEND;
|
|
36
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
KeystoneAnalyticsTracker,
|
|
12
12
|
} from '../../tracking';
|
|
13
13
|
import { ChatWidget } from '../../design_system/components/ChatWidget';
|
|
14
|
+
import { resolveChatBackend } from '../../lib/chat-backend';
|
|
14
15
|
import { FormDefinitionsProvider } from '../contexts/form-definitions';
|
|
15
16
|
import { KeystoneSSRProvider } from '../providers/ssr-provider';
|
|
16
17
|
import {
|
|
@@ -182,6 +183,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
|
182
183
|
const portalUrl = ci?.portal_url?.trim() || null;
|
|
183
184
|
const bookingHref = portalUrl ?? externalManagementUrl ?? null;
|
|
184
185
|
const chatEnabled = Boolean(ci?.chat_enabled);
|
|
186
|
+
// Which backend the site's /api/chat proxy targets — same resolution as the
|
|
187
|
+
// route handlers (CHAT_BACKEND override, else derived from API_URL).
|
|
188
|
+
const chatBackend = resolveChatBackend();
|
|
185
189
|
|
|
186
190
|
const headerOverrides = options?.headerOverrides;
|
|
187
191
|
const posthogHost = options?.posthogHost?.trim() || undefined;
|
|
@@ -229,6 +233,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
|
229
233
|
{chatEnabled ? (
|
|
230
234
|
<ChatWidget
|
|
231
235
|
position={options?.chatPosition || 'bottom-right'}
|
|
236
|
+
chatBackend={chatBackend}
|
|
232
237
|
teamMembers={teamMembers}
|
|
233
238
|
/>
|
|
234
239
|
) : null}
|
package/src/next/routes/chat.ts
CHANGED
|
@@ -5,13 +5,27 @@
|
|
|
5
5
|
* // app/api/chat/route.ts
|
|
6
6
|
* export { GET, POST } from 'keystone-design-bootstrap/next/routes/chat';
|
|
7
7
|
*
|
|
8
|
-
* Supports
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Supports TWO backends, selected per site (default: 'rails', so existing
|
|
9
|
+
* consumers are unaffected):
|
|
10
|
+
*
|
|
11
|
+
* createChatRouteHandlers({ backend: 'sor' }) // explicit option
|
|
12
|
+
* CHAT_BACKEND=sor // env override
|
|
13
|
+
* (otherwise derived from API_URL — a `sor` hostname label ⇒ 'sor';
|
|
14
|
+
* see lib/chat-backend.ts)
|
|
15
|
+
*
|
|
16
|
+
* - 'rails' (keystone-mono-proto): anonymous (`identifier`) and authenticated
|
|
17
|
+
* (`contact_id`) flows against `/public/messages`, plus the
|
|
18
|
+
* `action=realtime_token` + `cable_url` flow that feeds ActionCable.
|
|
19
|
+
* - 'sor' (sor-service): anonymous webchat against `/public/chat/*` —
|
|
20
|
+
* `session_id` (first-party cookie) + `{ messages, cursor }`, translated to
|
|
21
|
+
* the widget's existing wire shape. No realtime; the widget's polling
|
|
22
|
+
* fallback covers reply delivery. The `realtime_token` action returns an
|
|
23
|
+
* empty payload (no `cable_url`) so the widget knows realtime is unavailable.
|
|
11
24
|
*
|
|
12
25
|
* Env (server-side only):
|
|
13
|
-
* - API_URL (default: http://localhost:3000/api/v1)
|
|
14
|
-
* - API_KEY
|
|
26
|
+
* - API_URL (default: http://localhost:3000/api/v1) — backend base, includes /api/v1
|
|
27
|
+
* - API_KEY — business public key (X-API-Key)
|
|
28
|
+
* - CHAT_BACKEND ('rails' | 'sor', default 'rails')
|
|
15
29
|
*/
|
|
16
30
|
|
|
17
31
|
// IMPORTANT:
|
|
@@ -23,28 +37,31 @@
|
|
|
23
37
|
|
|
24
38
|
import { clientContextHeaders } from './proxy-headers';
|
|
25
39
|
import { serverError } from '../../lib/server-log';
|
|
40
|
+
import { resolveChatBackend, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
|
|
26
41
|
|
|
27
42
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
28
43
|
const API_KEY = process.env.API_KEY || '';
|
|
29
44
|
const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
|
|
45
|
+
const SESSION_COOKIE = 'ks_chat_session';
|
|
46
|
+
|
|
47
|
+
export type { ChatBackend };
|
|
30
48
|
|
|
31
49
|
type JsonResponder = (body: unknown, init?: ResponseInit) => Response;
|
|
32
50
|
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
.split('
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.slice(1)
|
|
41
|
-
.join('=');
|
|
42
|
-
return token ? decodeURIComponent(token) : null;
|
|
51
|
+
function readCookie(request: Request, name: string): string | null {
|
|
52
|
+
const header = request.headers.get('cookie') || '';
|
|
53
|
+
for (const part of header.split(';')) {
|
|
54
|
+
const [k, ...v] = part.trim().split('=');
|
|
55
|
+
if (k === name) return decodeURIComponent(v.join('='));
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
/* ------------------------------------------------------------------------ *
|
|
61
|
+
* rails backend (keystone-mono-proto)
|
|
62
|
+
* ------------------------------------------------------------------------ */
|
|
47
63
|
|
|
64
|
+
function createRailsHandlers(json: JsonResponder) {
|
|
48
65
|
return {
|
|
49
66
|
// POST /api/chat — send a message (session-based or contact-driven)
|
|
50
67
|
POST: async (request: Request): Promise<Response> => {
|
|
@@ -103,7 +120,7 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
103
120
|
|
|
104
121
|
const response = contactId
|
|
105
122
|
? await (async () => {
|
|
106
|
-
const consumerToken =
|
|
123
|
+
const consumerToken = readCookie(request, CONSUMER_TOKEN_COOKIE);
|
|
107
124
|
if (!consumerToken) {
|
|
108
125
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
109
126
|
status: 401,
|
|
@@ -163,3 +180,183 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
163
180
|
},
|
|
164
181
|
};
|
|
165
182
|
}
|
|
183
|
+
|
|
184
|
+
/* ------------------------------------------------------------------------ *
|
|
185
|
+
* sor backend (sor-service)
|
|
186
|
+
* ------------------------------------------------------------------------ */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Real browser signals to forward upstream. sor-service reads the visitor IP
|
|
190
|
+
* from X-Forwarded-For, the user-agent from User-Agent, and fbp/fbc from the
|
|
191
|
+
* POST body — so we set the headers and return fbp/fbc for the body.
|
|
192
|
+
*/
|
|
193
|
+
function clientContext(request: Request): {
|
|
194
|
+
headers: Record<string, string>;
|
|
195
|
+
fbp?: string;
|
|
196
|
+
fbc?: string;
|
|
197
|
+
} {
|
|
198
|
+
const ip =
|
|
199
|
+
request.headers.get('x-real-ip') ||
|
|
200
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
201
|
+
const ua = request.headers.get('user-agent') || undefined;
|
|
202
|
+
const headers: Record<string, string> = {};
|
|
203
|
+
if (ip) headers['X-Forwarded-For'] = ip;
|
|
204
|
+
if (ua) headers['User-Agent'] = ua;
|
|
205
|
+
return {
|
|
206
|
+
headers,
|
|
207
|
+
fbp: readCookie(request, '_fbp') || undefined,
|
|
208
|
+
fbc: readCookie(request, '_fbc') || undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sessionCookieHeader(sessionId: string): string {
|
|
213
|
+
// First-party (set by the site's own origin via this proxy), so it survives
|
|
214
|
+
// third-party-cookie blocking. 1-year, lax, http-only.
|
|
215
|
+
return `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface SorMessage {
|
|
219
|
+
id: string;
|
|
220
|
+
direction?: string;
|
|
221
|
+
sender_type?: string;
|
|
222
|
+
body?: string | null;
|
|
223
|
+
created_at?: string | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Translate a sor-service message to the widget's flat shape. */
|
|
227
|
+
function toWidgetMessage(m: SorMessage) {
|
|
228
|
+
const isAgent = m.direction === 'OUTBOUND' || (!!m.sender_type && m.sender_type !== 'CONSUMER');
|
|
229
|
+
return {
|
|
230
|
+
id: m.id,
|
|
231
|
+
body: m.body ?? '',
|
|
232
|
+
sender_type: isAgent ? 'agent' : 'contact',
|
|
233
|
+
sender_display_name: isAgent ? 'Assistant' : 'You',
|
|
234
|
+
created_at: m.created_at ?? new Date().toISOString(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createSorHandlers(json: JsonResponder) {
|
|
239
|
+
return {
|
|
240
|
+
// POST /api/chat — send a message (anonymous session flow)
|
|
241
|
+
POST: async (request: Request): Promise<Response> => {
|
|
242
|
+
try {
|
|
243
|
+
const body = await request.json();
|
|
244
|
+
const {
|
|
245
|
+
identifier,
|
|
246
|
+
session_id,
|
|
247
|
+
body: messageBody,
|
|
248
|
+
display_name,
|
|
249
|
+
email,
|
|
250
|
+
phone,
|
|
251
|
+
first_name,
|
|
252
|
+
last_name,
|
|
253
|
+
page_url,
|
|
254
|
+
} = body;
|
|
255
|
+
|
|
256
|
+
if (!messageBody || !String(messageBody).trim()) {
|
|
257
|
+
return json({ success: false, error: 'Message body is required.' }, { status: 400 });
|
|
258
|
+
}
|
|
259
|
+
const sessionId = identifier || session_id || readCookie(request, SESSION_COOKIE);
|
|
260
|
+
if (!sessionId) {
|
|
261
|
+
return json({ success: false, error: 'identifier (session) is required.' }, { status: 400 });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const ctx = clientContext(request);
|
|
265
|
+
const response = await fetch(`${API_URL}/public/chat/messages`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: {
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
'X-API-Key': API_KEY,
|
|
270
|
+
...ctx.headers,
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
session_id: sessionId,
|
|
274
|
+
body: messageBody,
|
|
275
|
+
display_name,
|
|
276
|
+
email,
|
|
277
|
+
phone,
|
|
278
|
+
first_name,
|
|
279
|
+
last_name,
|
|
280
|
+
page_url,
|
|
281
|
+
fbp: ctx.fbp,
|
|
282
|
+
fbc: ctx.fbc,
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const data = await response.json().catch(() => ({}));
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
return json(
|
|
289
|
+
{ success: false, error: data?.message || 'Failed to send message. Please try again.' },
|
|
290
|
+
{ status: response.status }
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Return a truthy `job_id` so the widget long-polls for the async AI
|
|
295
|
+
// reply — sentinel fallback because a falsy job_id would silently skip
|
|
296
|
+
// the reply wait (sor always dispatches the AI async).
|
|
297
|
+
const cursor = data?.data?.cursor ?? null;
|
|
298
|
+
return json(
|
|
299
|
+
{ success: true, data: { job_id: cursor ?? 'pending', cursor } },
|
|
300
|
+
{ headers: { 'Set-Cookie': sessionCookieHeader(sessionId) } }
|
|
301
|
+
);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
serverError('CHAT_ROUTE_POST_ERROR', error);
|
|
304
|
+
return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// GET /api/chat?identifier=... — load message history (flat array, widget shape)
|
|
309
|
+
GET: async (request: Request): Promise<Response> => {
|
|
310
|
+
try {
|
|
311
|
+
const { searchParams } = new URL(request.url);
|
|
312
|
+
|
|
313
|
+
// No ActionCable on sor-service: answer the widget's realtime probe with
|
|
314
|
+
// an empty payload (no cable_url) so it falls back to polling.
|
|
315
|
+
if (searchParams.get('action') === 'realtime_token') {
|
|
316
|
+
return json({ success: true, data: {} });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sessionId =
|
|
320
|
+
searchParams.get('identifier') ||
|
|
321
|
+
searchParams.get('session_id') ||
|
|
322
|
+
readCookie(request, SESSION_COOKIE);
|
|
323
|
+
|
|
324
|
+
if (!sessionId) {
|
|
325
|
+
return json({ success: true, data: [] });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const query = new URLSearchParams({ session_id: sessionId, wait: '0' });
|
|
329
|
+
const since = searchParams.get('since');
|
|
330
|
+
if (since) query.set('since', since);
|
|
331
|
+
|
|
332
|
+
const response = await fetch(`${API_URL}/public/chat/messages?${query.toString()}`, {
|
|
333
|
+
headers: { 'X-API-Key': API_KEY },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const data = await response.json().catch(() => ({}));
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
return json(
|
|
339
|
+
{ success: false, error: data?.message || 'Failed to load messages.' },
|
|
340
|
+
{ status: response.status }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const messages = (data?.data?.messages ?? []) as SorMessage[];
|
|
345
|
+
return json({ success: true, data: messages.map(toWidgetMessage) });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
serverError('CHAT_ROUTE_GET_ERROR', error);
|
|
348
|
+
return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function createChatRouteHandlers(deps?: {
|
|
355
|
+
NextResponse?: { json: JsonResponder };
|
|
356
|
+
backend?: ChatBackend;
|
|
357
|
+
}) {
|
|
358
|
+
const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
|
|
359
|
+
const backend: ChatBackend = deps?.backend ?? resolveChatBackend();
|
|
360
|
+
|
|
361
|
+
return backend === SOR_BACKEND ? createSorHandlers(json) : createRailsHandlers(json);
|
|
362
|
+
}
|