Skip to content

Commit e9b0794

Browse files
committed
fix(manage-assistant): refresh the question pool when the assistant creates a draft
The chat iframe caches the verified manage parent origin (zustand store, set only after a context message passes validation) and posts a klicker:manage-element-created message to that exact origin after a successful proposal confirm. The widget validates the payload strictly behind its existing origin+source checks, refetches GetUserElements, and fires a success toast (new i18n key, en+de). Standalone chat tabs skip the notify silently. Confirm double-submit was already guarded.
1 parent 94e6ea5 commit e9b0794

11 files changed

Lines changed: 217 additions & 2 deletions

File tree

apps/chat/src/components/manage-proposal-card.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { CheckIcon, LoaderCircleIcon } from 'lucide-react'
44
import { useState, type FC } from 'react'
5+
import { notifyManageParent } from '../services/manageParentNotify'
56
import { parseManageProposalPayload } from '../services/proposalToElementInstance'
67
import { ManageProposalPreview } from './manage-proposal-preview'
78
import { formatToolName } from './tool-labels'
@@ -115,6 +116,7 @@ export const ManageProposalCard: FC<ManageProposalCardProps> = ({
115116
}
116117

117118
setConfirmation({ type: 'success', element: data.element })
119+
notifyManageParent({ id: data.element.id, name: data.element.name })
118120
} catch (error) {
119121
setConfirmation({
120122
message:

apps/chat/src/hooks/useEmbeddedManageContext.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
sanitizeManageAssistantContext,
66
type ManageAssistantContext,
77
} from '../services/manageContext'
8+
import { useManageParentStore } from '../stores/manageParentStore'
89
import { useEmbedded } from './useEmbedded'
910

1011
const MANAGE_CONTEXT_MESSAGE_TYPE = 'klicker:manage-context'
@@ -15,11 +16,15 @@ export function useEmbeddedManageContext() {
1516
const embedded = useEmbedded()
1617
const [context, setContext] = useState<ManageAssistantContext | null>(null)
1718
const contextKeyRef = useRef<string | null>(null)
19+
const setManageParentOrigin = useManageParentStore(
20+
(state) => state.setManageParentOrigin
21+
)
1822

1923
useEffect(() => {
2024
if (!embedded) {
2125
contextKeyRef.current = null
2226
setContext(null)
27+
setManageParentOrigin(null)
2328
return
2429
}
2530

@@ -30,6 +35,11 @@ export function useEmbeddedManageContext() {
3035
const nextContext = sanitizeManageAssistantContext(event.data.payload)
3136
if (!nextContext) return
3237

38+
// The message passed every validation check above, so event.origin is
39+
// the verified Manage parent origin. Cache it for components outside
40+
// this hook (e.g. the proposal card) that need to postMessage back.
41+
setManageParentOrigin(event.origin)
42+
3343
const messageId =
3444
typeof event.data.messageId === 'number' ? event.data.messageId : null
3545

@@ -65,7 +75,7 @@ export function useEmbeddedManageContext() {
6575
window.parent.postMessage({ type: MANAGE_CONTEXT_READY_MESSAGE_TYPE }, '*')
6676

6777
return () => window.removeEventListener('message', handleMessage)
68-
}, [embedded])
78+
}, [embedded, setManageParentOrigin])
6979

7080
return context
7181
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useManageParentStore } from '../stores/manageParentStore'
2+
3+
export const MANAGE_ELEMENT_CREATED_MESSAGE_TYPE =
4+
'klicker:manage-element-created'
5+
6+
export type ManageElementCreatedPayload = {
7+
id: number
8+
name: string
9+
}
10+
11+
export function buildManageElementCreatedMessage(
12+
payload: ManageElementCreatedPayload
13+
) {
14+
return {
15+
type: MANAGE_ELEMENT_CREATED_MESSAGE_TYPE,
16+
payload,
17+
} as const
18+
}
19+
20+
// Tells the embedding Manage parent that a proposal was confirmed into a new
21+
// question-pool element, so it can refresh its own data without a reload.
22+
// Silently does nothing when there is no cached parent origin: that means
23+
// this chat instance is not embedded in a Manage tab (e.g. a standalone chat
24+
// session), so there is no parent to refresh. The cached origin always comes
25+
// from a validated `klicker:manage-context` message (see
26+
// useEmbeddedManageContext), so it is safe to target directly instead of '*'.
27+
export function notifyManageParent(payload: ManageElementCreatedPayload) {
28+
const manageParentOrigin = useManageParentStore.getState().manageParentOrigin
29+
if (!manageParentOrigin) return
30+
31+
window.parent.postMessage(
32+
buildManageElementCreatedMessage(payload),
33+
manageParentOrigin
34+
)
35+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client'
2+
import { create } from 'zustand'
3+
4+
// Caches the verified origin of the embedding Manage parent window once a
5+
// `klicker:manage-context` message from it has passed validation. Kept as a
6+
// standalone store (rather than folded into chatContextStore, which tracks
7+
// the unrelated PWA embed context) because the two embeddings have
8+
// independent lifecycles and message types.
9+
type ManageParentState = {
10+
manageParentOrigin: string | null
11+
setManageParentOrigin: (manageParentOrigin: string | null) => void
12+
}
13+
14+
export const useManageParentStore = create<ManageParentState>((set) => ({
15+
manageParentOrigin: null,
16+
setManageParentOrigin: (manageParentOrigin) => set({ manageParentOrigin }),
17+
}))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { afterEach, describe, expect, test } from 'vitest'
2+
import {
3+
buildManageElementCreatedMessage,
4+
notifyManageParent,
5+
} from '../src/services/manageParentNotify'
6+
import { useManageParentStore } from '../src/stores/manageParentStore'
7+
8+
afterEach(() => {
9+
useManageParentStore.setState({ manageParentOrigin: null })
10+
})
11+
12+
describe('Manage parent notify', () => {
13+
test('builds a typed element-created message envelope', () => {
14+
expect(
15+
buildManageElementCreatedMessage({ id: 42, name: 'Draft question' })
16+
).toEqual({
17+
type: 'klicker:manage-element-created',
18+
payload: { id: 42, name: 'Draft question' },
19+
})
20+
})
21+
22+
test('does nothing when no manage parent origin is cached', () => {
23+
useManageParentStore.setState({ manageParentOrigin: null })
24+
25+
// The vitest environment for this suite is `node`, so `window` is
26+
// undefined. If notifyManageParent tried to reach window.parent it would
27+
// throw a ReferenceError, so a non-throwing call here proves the guard
28+
// short-circuits before touching the DOM - i.e. before a non-embedded
29+
// chat session (no verified parent origin) ever posts a message.
30+
expect(() =>
31+
notifyManageParent({ id: 1, name: 'Draft question' })
32+
).not.toThrow()
33+
})
34+
})

apps/frontend-manage/src/components/assistant/ManageAssistantWidget.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { useApolloClient } from '@apollo/client'
12
import {
23
faArrowUpRightFromSquare,
34
faWandMagicSparkles,
45
faXmark,
56
} from '@fortawesome/free-solid-svg-icons'
67
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
8+
import { GetUserElementsDocument } from '@klicker-uzh/graphql/dist/ops'
9+
import { toast } from '@uzh-bf/design-system'
710
import { useTranslations } from 'next-intl'
811
import { useRouter } from 'next/router'
912
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -17,6 +20,10 @@ import {
1720
buildManageAssistantContext,
1821
type ManageAssistantContext,
1922
} from './manageAssistantContext'
23+
import {
24+
isManageElementCreatedMessage,
25+
sanitizeManageElementCreatedPayload,
26+
} from './manageElementCreatedMessage'
2027

2128
const MANAGE_CONTEXT_MESSAGE_TYPE = 'klicker:manage-context'
2229
const MANAGE_CONTEXT_ACK_MESSAGE_TYPE = 'klicker:manage-context-ack'
@@ -25,6 +32,7 @@ const MANAGE_CONTEXT_READY_MESSAGE_TYPE = 'klicker:manage-context-ready'
2532
export function ManageAssistantWidget() {
2633
const t = useTranslations()
2734
const router = useRouter()
35+
const apolloClient = useApolloClient()
2836
const iframeRef = useRef<HTMLIFrameElement | null>(null)
2937
const triggerRef = useRef<HTMLButtonElement | null>(null)
3038
const shouldRestoreFocusRef = useRef(false)
@@ -133,12 +141,30 @@ export function ManageAssistantWidget() {
133141
// before a slow-hydrating iframe is able to receive anything.
134142
if (isManageContextReadyMessage(event.data)) {
135143
sendCurrentContext()
144+
return
145+
}
146+
147+
// A confirmed proposal created a new question-pool element. The
148+
// payload crossed a postMessage boundary from the iframe, so treat it
149+
// as untrusted data rather than an instruction: validate its shape
150+
// before using it for anything, and never render it as HTML.
151+
if (isManageElementCreatedMessage(event.data)) {
152+
const element = sanitizeManageElementCreatedPayload(event.data.payload)
153+
if (!element) return
154+
155+
apolloClient.refetchQueries({ include: [GetUserElementsDocument] })
156+
toast({
157+
type: 'success',
158+
message: t('manage.assistant.elementCreatedToast', {
159+
name: element.name,
160+
}),
161+
})
136162
}
137163
}
138164

139165
window.addEventListener('message', handleMessage)
140166
return () => window.removeEventListener('message', handleMessage)
141-
}, [assistantOrigin, open, sendCurrentContext])
167+
}, [apolloClient, assistantOrigin, open, sendCurrentContext, t])
142168

143169
useEffect(() => {
144170
if (!open || !frameLoaded || !assistantOrigin || !iframeRef.current) return
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const MAX_NAME_LENGTH = 200
2+
3+
export const MANAGE_ELEMENT_CREATED_MESSAGE_TYPE =
4+
'klicker:manage-element-created'
5+
6+
export type ManageElementCreatedPayload = {
7+
id: number
8+
name: string
9+
}
10+
11+
export function isManageElementCreatedMessage(data: unknown): data is {
12+
type: typeof MANAGE_ELEMENT_CREATED_MESSAGE_TYPE
13+
payload: unknown
14+
} {
15+
return (
16+
typeof data === 'object' &&
17+
data !== null &&
18+
(data as { type?: unknown }).type === MANAGE_ELEMENT_CREATED_MESSAGE_TYPE
19+
)
20+
}
21+
22+
// The payload crosses a postMessage boundary from the embedded assistant
23+
// iframe, so it is untrusted data, not an instruction: validate its shape
24+
// and bounds strictly rather than trusting the sender.
25+
export function sanitizeManageElementCreatedPayload(
26+
payload: unknown
27+
): ManageElementCreatedPayload | null {
28+
if (typeof payload !== 'object' || payload === null) return null
29+
30+
const { id, name } = payload as Record<string, unknown>
31+
32+
if (typeof id !== 'number' || !Number.isFinite(id)) return null
33+
if (
34+
typeof name !== 'string' ||
35+
name.length === 0 ||
36+
name.length > MAX_NAME_LENGTH
37+
) {
38+
return null
39+
}
40+
41+
return { id, name }
42+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from 'node:assert/strict'
2+
import {
3+
isManageElementCreatedMessage,
4+
sanitizeManageElementCreatedPayload,
5+
} from '../src/components/assistant/manageElementCreatedMessage'
6+
7+
assert.equal(
8+
isManageElementCreatedMessage({
9+
type: 'klicker:manage-element-created',
10+
payload: { id: 1, name: 'Draft question' },
11+
}),
12+
true
13+
)
14+
assert.equal(isManageElementCreatedMessage({ type: 'klicker:other' }), false)
15+
assert.equal(isManageElementCreatedMessage(null), false)
16+
assert.equal(
17+
isManageElementCreatedMessage('klicker:manage-element-created'),
18+
false
19+
)
20+
21+
assert.deepEqual(
22+
sanitizeManageElementCreatedPayload({ id: 42, name: 'Draft question' }),
23+
{ id: 42, name: 'Draft question' }
24+
)
25+
26+
// Rejects malformed or out-of-bounds payloads instead of trusting the
27+
// postMessage sender.
28+
assert.equal(
29+
sanitizeManageElementCreatedPayload({ id: '42', name: 'Draft question' }),
30+
null
31+
)
32+
assert.equal(sanitizeManageElementCreatedPayload({ id: 42, name: 123 }), null)
33+
assert.equal(sanitizeManageElementCreatedPayload({ id: 42, name: '' }), null)
34+
assert.equal(
35+
sanitizeManageElementCreatedPayload({ id: 42, name: 'x'.repeat(201) }),
36+
null
37+
)
38+
assert.equal(
39+
sanitizeManageElementCreatedPayload({
40+
id: Number.POSITIVE_INFINITY,
41+
name: 'Draft question',
42+
}),
43+
null
44+
)
45+
assert.equal(sanitizeManageElementCreatedPayload({ id: 42 }), null)
46+
assert.equal(sanitizeManageElementCreatedPayload(null), null)

packages/i18n/messages/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die
12191219
title: 'KlickerUZH Assistant',
12201220
subtitle: 'Manage',
12211221
openInNewTab: 'Assistent in einem neuen Tab öffnen',
1222+
elementCreatedToast: 'Entwurf "{name}" zum Fragepool hinzugefügt',
12221223
},
12231224
general: {
12241225
qrCode: 'QR Code',

packages/i18n/messages/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,7 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these
12181218
title: 'KlickerUZH Assistant',
12191219
subtitle: 'Manage',
12201220
openInNewTab: 'Open assistant in a new tab',
1221+
elementCreatedToast: 'Draft "{name}" added to your question pool',
12211222
},
12221223
general: {
12231224
qrCode: 'QR Code',

0 commit comments

Comments
 (0)