@tinyweb_dev/oe-meeting-react 0.1.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/OeMeeting.tsx","../src/embed-protocol.ts","../src/buildEmbedUrl.ts","../src/useOeMeeting.ts","../src/parseEmbedEvent.ts"],"sourcesContent":["'use client';\n\nimport {\n forwardRef,\n useId,\n useMemo,\n type CSSProperties,\n} from 'react';\nimport { buildEmbedUrl } from './buildEmbedUrl';\nimport { DEFAULT_EMBED_ORIGIN } from './embed-protocol';\nimport type { OeMeetingHandle, OeMeetingProps } from './types';\nimport { useOeMeeting } from './useOeMeeting';\n\nexport const DEFAULT_IFRAME_ALLOW =\n 'camera; microphone; fullscreen; autoplay; display-capture; clipboard-write; encrypted-media';\n\nfunction toCssSize(value: number | string | undefined): string | undefined {\n if (value === undefined) return undefined;\n return typeof value === 'number' ? `${value}px` : value;\n}\n\nfunction getOrigin(value: string): string {\n return new URL(value).origin;\n}\n\nexport const OeMeeting = forwardRef<OeMeetingHandle, OeMeetingProps>(\n function OeMeeting(props, ref) {\n const {\n roomSlug,\n joinToken,\n userName,\n externalUserId,\n origin = DEFAULT_EMBED_ORIGIN,\n lang,\n autoJoin = true,\n hideLeave,\n title = 'OE Meeting room',\n width = '100%',\n height = 640,\n className,\n style,\n iframeClassName,\n iframeStyle,\n allow = DEFAULT_IFRAME_ALLOW,\n targetOrigin,\n onReady,\n onJoined,\n onLeft,\n onParticipantJoined,\n onParticipantLeft,\n onEnded,\n onError,\n onEvent,\n } = props;\n const reactId = useId();\n const instanceId = useMemo(\n () => `oem-${reactId.replace(/:/g, '')}`,\n [reactId],\n );\n const embedOrigin = useMemo(() => getOrigin(origin), [origin]);\n const resolvedTargetOrigin = targetOrigin ?? embedOrigin;\n const hostOrigin =\n typeof window === 'undefined' ? undefined : window.location.origin;\n const src = useMemo(\n () =>\n buildEmbedUrl({\n roomSlug,\n joinToken,\n userName,\n externalUserId,\n origin,\n lang,\n autoJoin,\n hideLeave,\n instanceId,\n hostOrigin,\n }),\n [\n autoJoin,\n externalUserId,\n hideLeave,\n hostOrigin,\n instanceId,\n joinToken,\n lang,\n origin,\n roomSlug,\n userName,\n ],\n );\n const { iframeRef } = useOeMeeting({\n instanceId,\n targetOrigin: resolvedTargetOrigin,\n handleRef: ref,\n onReady,\n onJoined,\n onLeft,\n onParticipantJoined,\n onParticipantLeft,\n onEnded,\n onError,\n onEvent,\n });\n const wrapperStyle: CSSProperties = {\n width: toCssSize(width),\n height: toCssSize(height),\n ...style,\n };\n const frameStyle: CSSProperties = {\n width: '100%',\n height: '100%',\n border: 0,\n display: 'block',\n ...iframeStyle,\n };\n\n return (\n <div\n className={className}\n style={wrapperStyle}\n data-oe-meeting-instance={instanceId}\n >\n <iframe\n ref={iframeRef}\n className={iframeClassName}\n style={frameStyle}\n src={src}\n title={title}\n allow={allow}\n allowFullScreen\n referrerPolicy=\"strict-origin-when-cross-origin\"\n />\n </div>\n );\n },\n);\n\nOeMeeting.displayName = 'OeMeeting';\n","export const EMBED_PROTOCOL_VERSION = 1 as const;\nexport const EMBED_MESSAGE_SOURCE = 'oemeeting' as const;\nexport const DEFAULT_EMBED_ORIGIN = 'https://meet.oceanedu.org';\n\nexport enum EmbedEventType {\n READY = 'oemeeting:ready',\n JOINED = 'oemeeting:joined',\n LEFT = 'oemeeting:left',\n PARTICIPANT_JOINED = 'oemeeting:participant-joined',\n PARTICIPANT_LEFT = 'oemeeting:participant-left',\n ENDED = 'oemeeting:ended',\n ERROR = 'oemeeting:error',\n}\n\nexport enum EmbedCommandType {\n COMMAND = 'oemeeting:command',\n}\n\nexport enum EmbedCommandAction {\n LEAVE = 'leave',\n TOGGLE_MIC = 'toggleMic',\n TOGGLE_CAMERA = 'toggleCamera',\n}\n\nexport enum EmbedErrorCode {\n TOKEN_INVALID = 'TOKEN_INVALID',\n TOKEN_EXPIRED = 'TOKEN_EXPIRED',\n ROOM_NOT_FOUND = 'ROOM_NOT_FOUND',\n ROOM_ACCESS_DENIED = 'ROOM_ACCESS_DENIED',\n CONNECTION_FAILED = 'CONNECTION_FAILED',\n PROTOCOL_MISMATCH = 'PROTOCOL_MISMATCH',\n UNKNOWN = 'UNKNOWN',\n}\n\nexport enum EmbedQueryParam {\n INSTANCE_ID = 'instanceId',\n HOST_ORIGIN = 'hostOrigin',\n JOIN_TOKEN = 'joinToken',\n USER_NAME = 'userName',\n EXTERNAL_USER_ID = 'externalUserId',\n LANG = 'lang',\n AUTO_JOIN = 'autoJoin',\n HIDE_LEAVE = 'hideLeave',\n}\n","import {\n DEFAULT_EMBED_ORIGIN,\n EmbedQueryParam,\n} from './embed-protocol';\nimport type { BuildEmbedUrlOptions } from './types';\n\nconst QUERY_FLAG_ENABLED = '1';\nconst QUERY_FLAG_DISABLED = '0';\n\nfunction toFlag(value: boolean | undefined): string | undefined {\n if (value === undefined) return undefined;\n return value ? QUERY_FLAG_ENABLED : QUERY_FLAG_DISABLED;\n}\n\nfunction normalizeOrigin(origin: string): string {\n return origin.replace(/\\/+$/, '');\n}\n\nexport function buildEmbedUrl(options: BuildEmbedUrlOptions): string {\n const roomSlug = options.roomSlug.trim();\n const joinToken = options.joinToken.trim();\n if (!roomSlug) {\n throw new Error('OE Meeting embed requires a non-empty roomSlug.');\n }\n if (!joinToken) {\n throw new Error('OE Meeting embed requires a non-empty joinToken.');\n }\n\n const origin = normalizeOrigin(options.origin ?? DEFAULT_EMBED_ORIGIN);\n const url = new URL(`${origin}/embed/${encodeURIComponent(roomSlug)}`);\n const entries: Array<[EmbedQueryParam, string | undefined]> = [\n [EmbedQueryParam.INSTANCE_ID, options.instanceId],\n [EmbedQueryParam.HOST_ORIGIN, options.hostOrigin],\n [EmbedQueryParam.JOIN_TOKEN, joinToken],\n [EmbedQueryParam.USER_NAME, options.userName],\n [EmbedQueryParam.EXTERNAL_USER_ID, options.externalUserId],\n [EmbedQueryParam.LANG, options.lang],\n [EmbedQueryParam.AUTO_JOIN, toFlag(options.autoJoin)],\n [EmbedQueryParam.HIDE_LEAVE, toFlag(options.hideLeave)],\n ];\n\n for (const [key, value] of entries) {\n if (value !== undefined && value !== '') {\n url.searchParams.set(key, value);\n }\n }\n return url.toString();\n}\n","import {\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n type Ref,\n} from 'react';\nimport {\n EMBED_MESSAGE_SOURCE,\n EMBED_PROTOCOL_VERSION,\n EmbedCommandAction,\n EmbedCommandType,\n EmbedEventType,\n} from './embed-protocol';\nimport { parseEmbedEvent } from './parseEmbedEvent';\nimport type {\n EmbedCommandMessage,\n OeMeetingHandle,\n OeMeetingProps,\n} from './types';\n\ntype UseOeMeetingOptions = Pick<\n OeMeetingProps,\n | 'onReady'\n | 'onJoined'\n | 'onLeft'\n | 'onParticipantJoined'\n | 'onParticipantLeft'\n | 'onEnded'\n | 'onError'\n | 'onEvent'\n> & {\n instanceId: string;\n targetOrigin: string;\n handleRef?: Ref<OeMeetingHandle>;\n};\n\ninterface PostCommandOptions {\n iframe: HTMLIFrameElement | null;\n instanceId: string;\n action: EmbedCommandAction;\n targetOrigin: string;\n}\n\nfunction postCommand(options: PostCommandOptions): void {\n const frameWindow = options.iframe?.contentWindow;\n if (!frameWindow) return;\n const message: EmbedCommandMessage = {\n source: EMBED_MESSAGE_SOURCE,\n version: EMBED_PROTOCOL_VERSION,\n instanceId: options.instanceId,\n type: EmbedCommandType.COMMAND,\n action: options.action,\n };\n frameWindow.postMessage(message, options.targetOrigin);\n}\n\nexport function useOeMeeting(options: UseOeMeetingOptions) {\n const iframeRef = useRef<HTMLIFrameElement | null>(null);\n const callbacksRef = useRef(options);\n\n useEffect(() => {\n callbacksRef.current = options;\n }, [options]);\n\n useEffect(() => {\n const onMessage = (event: MessageEvent<unknown>) => {\n const frameWindow = iframeRef.current?.contentWindow;\n if (\n !frameWindow ||\n event.source !== frameWindow ||\n event.origin !== options.targetOrigin\n ) {\n return;\n }\n const embedEvent = parseEmbedEvent(event.data);\n if (!embedEvent || embedEvent.instanceId !== options.instanceId) return;\n\n const callbacks = callbacksRef.current;\n callbacks.onEvent?.(embedEvent);\n switch (embedEvent.type) {\n case EmbedEventType.READY:\n callbacks.onReady?.(embedEvent);\n break;\n case EmbedEventType.JOINED:\n callbacks.onJoined?.(embedEvent);\n break;\n case EmbedEventType.LEFT:\n callbacks.onLeft?.(embedEvent);\n break;\n case EmbedEventType.PARTICIPANT_JOINED:\n callbacks.onParticipantJoined?.(embedEvent);\n break;\n case EmbedEventType.PARTICIPANT_LEFT:\n callbacks.onParticipantLeft?.(embedEvent);\n break;\n case EmbedEventType.ENDED:\n callbacks.onEnded?.(embedEvent);\n break;\n case EmbedEventType.ERROR:\n callbacks.onError?.(embedEvent);\n break;\n }\n };\n\n window.addEventListener('message', onMessage);\n return () => window.removeEventListener('message', onMessage);\n }, [options.instanceId, options.targetOrigin]);\n\n const send = useCallback(\n (action: EmbedCommandAction) =>\n postCommand({\n iframe: iframeRef.current,\n instanceId: options.instanceId,\n action,\n targetOrigin: options.targetOrigin,\n }),\n [options.instanceId, options.targetOrigin],\n );\n const leave = useCallback(() => send(EmbedCommandAction.LEAVE), [send]);\n const toggleMic = useCallback(\n () => send(EmbedCommandAction.TOGGLE_MIC),\n [send],\n );\n const toggleCamera = useCallback(\n () => send(EmbedCommandAction.TOGGLE_CAMERA),\n [send],\n );\n const getIframe = useCallback(() => iframeRef.current, []);\n const handle = useMemo<OeMeetingHandle>(\n () => ({ leave, toggleMic, toggleCamera, getIframe }),\n [getIframe, leave, toggleCamera, toggleMic],\n );\n\n useImperativeHandle(options.handleRef, () => handle, [handle]);\n return { iframeRef, handle };\n}\n","import {\n EMBED_MESSAGE_SOURCE,\n EMBED_PROTOCOL_VERSION,\n EmbedEventType,\n} from './embed-protocol';\nimport type { EmbedEvent } from './types';\n\nconst EVENT_TYPES = new Set<string>(Object.values(EmbedEventType));\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction isOptionalString(value: unknown): boolean {\n return value === undefined || typeof value === 'string';\n}\n\nfunction hasValidPayload(\n type: EmbedEventType,\n payload: Record<string, unknown>,\n): boolean {\n switch (type) {\n case EmbedEventType.READY:\n return typeof payload.roomSlug === 'string';\n case EmbedEventType.JOINED:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.identity === 'string' &&\n isOptionalString(payload.name) &&\n typeof payload.joinedAt === 'string'\n );\n case EmbedEventType.LEFT:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.identity === 'string' &&\n typeof payload.leftAt === 'string' &&\n (payload.durationMs === undefined ||\n typeof payload.durationMs === 'number')\n );\n case EmbedEventType.PARTICIPANT_JOINED:\n return (\n typeof payload.identity === 'string' &&\n isOptionalString(payload.name) &&\n typeof payload.participantCount === 'number'\n );\n case EmbedEventType.PARTICIPANT_LEFT:\n return (\n typeof payload.identity === 'string' &&\n typeof payload.participantCount === 'number'\n );\n case EmbedEventType.ENDED:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.endedAt === 'string'\n );\n case EmbedEventType.ERROR:\n return (\n typeof payload.code === 'string' &&\n typeof payload.message === 'string'\n );\n }\n}\n\nexport function parseEmbedEvent(data: unknown): EmbedEvent | null {\n if (!isRecord(data)) return null;\n if (\n data.source !== EMBED_MESSAGE_SOURCE ||\n data.version !== EMBED_PROTOCOL_VERSION ||\n typeof data.instanceId !== 'string' ||\n data.instanceId.length === 0 ||\n typeof data.type !== 'string' ||\n !EVENT_TYPES.has(data.type) ||\n !isRecord(data.payload)\n ) {\n return null;\n }\n\n const type = data.type as EmbedEventType;\n if (!hasValidPayload(type, data.payload)) return null;\n return data as unknown as EmbedEvent;\n}\n"],"mappings":"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","names":["useMemo","EmbedEventType","EmbedCommandType","EmbedCommandAction","EmbedErrorCode","EmbedQueryParam","OeMeeting","useMemo"]}
@@ -0,0 +1,287 @@
1
+ # Tích hợp backend OE.School với OE Meeting
2
+
3
+ ## 1. Chuẩn bị credentials
4
+
5
+ OE Meeting cần cấp cho OE.School một application credential gồm:
6
+
7
+ - `clientId`.
8
+ - `clientSecret`.
9
+ - Scope `rooms:create` để tạo phòng.
10
+ - Scope `rooms:retrieve` để đọc phòng và cấp join token.
11
+ - Tùy nhu cầu quản trị: `rooms:update`, `rooms:delete`.
12
+
13
+ Cấu hình ở backend OE.School, ví dụ:
14
+
15
+ ```dotenv
16
+ OE_MEETING_API_URL=https://meet.oceanedu.org
17
+ OE_MEETING_CLIENT_ID=oe-school
18
+ OE_MEETING_CLIENT_SECRET=<secret-from-secret-manager>
19
+ ```
20
+
21
+ Không dùng biến public như `NEXT_PUBLIC_*` hoặc `VITE_*` cho client secret.
22
+ Production nên lấy secret từ secret manager hoặc Kubernetes Secret, không commit
23
+ vào repository.
24
+
25
+ ## 2. External API
26
+
27
+ Base URL production:
28
+
29
+ ```text
30
+ https://meet.oceanedu.org/external-api/v1.0
31
+ ```
32
+
33
+ Mọi request server-to-server phải gửi:
34
+
35
+ ```http
36
+ X-Client-Id: <client-id>
37
+ X-Client-Secret: <client-secret>
38
+ Content-Type: application/json
39
+ ```
40
+
41
+ ### 2.1. Tạo phòng cho buổi học
42
+
43
+ ```http
44
+ POST /external-api/v1.0/rooms
45
+ ```
46
+
47
+ Request:
48
+
49
+ ```json
50
+ {
51
+ "name": "Lesson 98765 - 2026-08-17",
52
+ "slug": "oe-school-lesson-98765",
53
+ "access_level": "restricted",
54
+ "configuration": {}
55
+ }
56
+ ```
57
+
58
+ | Field | Bắt buộc | Mô tả |
59
+ |---|---:|---|
60
+ | `name` | Có | Tên hiển thị, tối đa 500 ký tự. |
61
+ | `slug` | Không | Khóa ổn định của phòng, tối đa 100 ký tự. Nếu bỏ trống, server sinh từ `name`. |
62
+ | `access_level` | Không | Nên luôn dùng `restricted`; external API mặc định là `restricted`. |
63
+ | `configuration` | Không | Cấu hình mở rộng của phòng. |
64
+
65
+ Response mẫu:
66
+
67
+ ```json
68
+ {
69
+ "id": "c4ac4f15-8417-4e19-a3dd-39de5697c19f",
70
+ "name": "Lesson 98765 - 2026-08-17",
71
+ "slug": "oe-school-lesson-98765",
72
+ "accessLevel": "restricted",
73
+ "configuration": {},
74
+ "pinCode": null
75
+ }
76
+ ```
77
+
78
+ API idempotent theo `slug`: gọi lại cùng slug trả về phòng đã tồn tại. OE.School
79
+ nên lưu `room.id` và `room.slug` vào bản ghi buổi học sau lần tạo đầu tiên.
80
+
81
+ Khuyến nghị slug:
82
+
83
+ ```text
84
+ oe-school-lesson-<lessonId>
85
+ ```
86
+
87
+ Không dùng tên học sinh, email hoặc dữ liệu cá nhân trong slug vì slug xuất hiện
88
+ trong URL.
89
+
90
+ ### 2.2. Cấp join token
91
+
92
+ Chỉ gọi endpoint này **sau khi backend OE.School kiểm tra roster**.
93
+
94
+ ```http
95
+ POST /external-api/v1.0/application/token
96
+ ```
97
+
98
+ Request:
99
+
100
+ ```json
101
+ {
102
+ "room": "oe-school-lesson-98765",
103
+ "identity": "oe-school:user:12345",
104
+ "name": "Nguyen Van A"
105
+ }
106
+ ```
107
+
108
+ | Field | Bắt buộc | Mô tả |
109
+ |---|---:|---|
110
+ | `room` | Có | Room slug đã tạo, tối đa 255 ký tự. |
111
+ | `identity` | Có | Định danh ổn định và duy nhất của user, tối đa 255 ký tự. |
112
+ | `name` | Không | Tên hiển thị trong phòng, tối đa 255 ký tự. |
113
+
114
+ Response:
115
+
116
+ ```json
117
+ {
118
+ "token": "<livekit-join-token>"
119
+ }
120
+ ```
121
+
122
+ Nên dùng identity có namespace, ví dụ `oe-school:user:<userId>`, để tránh trùng
123
+ identity giữa các hệ thống tích hợp. Không dùng token phiên đăng nhập OE.School
124
+ làm identity.
125
+
126
+ ### 2.3. Các endpoint quản trị khác
127
+
128
+ | Method | Endpoint | Scope |
129
+ |---|---|---|
130
+ | `GET` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:retrieve` |
131
+ | `PATCH` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:update` |
132
+ | `DELETE` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:delete` |
133
+
134
+ ## 3. Service mẫu ở backend OE.School
135
+
136
+ Ví dụ dưới đây dùng `fetch` tích hợp sẵn trong Node.js 18+.
137
+
138
+ ```typescript
139
+ const RoomAccessLevel = {
140
+ RESTRICTED: 'restricted',
141
+ } as const;
142
+
143
+ interface OeMeetingRoom {
144
+ id: string;
145
+ name: string;
146
+ slug: string;
147
+ accessLevel: (typeof RoomAccessLevel)[keyof typeof RoomAccessLevel];
148
+ }
149
+
150
+ interface JoinTokenResponse {
151
+ token: string;
152
+ }
153
+
154
+ interface MeetingCredentials {
155
+ apiUrl: string;
156
+ clientId: string;
157
+ clientSecret: string;
158
+ }
159
+
160
+ export class OeMeetingApi {
161
+ constructor(private readonly credentials: MeetingCredentials) {}
162
+
163
+ private async request<T>(path: string, init: RequestInit): Promise<T> {
164
+ const response = await fetch(`${this.credentials.apiUrl}${path}`, {
165
+ ...init,
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ 'X-Client-Id': this.credentials.clientId,
169
+ 'X-Client-Secret': this.credentials.clientSecret,
170
+ ...init.headers,
171
+ },
172
+ });
173
+
174
+ if (!response.ok) {
175
+ throw new Error(`OE Meeting API failed with status ${response.status}`);
176
+ }
177
+
178
+ return (await response.json()) as T;
179
+ }
180
+
181
+ createLessonRoom(input: {
182
+ lessonId: string;
183
+ lessonName: string;
184
+ }): Promise<OeMeetingRoom> {
185
+ return this.request('/external-api/v1.0/rooms', {
186
+ method: 'POST',
187
+ body: JSON.stringify({
188
+ name: input.lessonName,
189
+ slug: `oe-school-lesson-${input.lessonId}`,
190
+ access_level: RoomAccessLevel.RESTRICTED,
191
+ }),
192
+ });
193
+ }
194
+
195
+ createJoinToken(input: {
196
+ roomSlug: string;
197
+ userId: string;
198
+ displayName: string;
199
+ }): Promise<JoinTokenResponse> {
200
+ return this.request('/external-api/v1.0/application/token', {
201
+ method: 'POST',
202
+ body: JSON.stringify({
203
+ room: input.roomSlug,
204
+ identity: `oe-school:user:${input.userId}`,
205
+ name: input.displayName,
206
+ }),
207
+ });
208
+ }
209
+ }
210
+ ```
211
+
212
+ Trong code production, validate response bằng schema validator hiện có của
213
+ OE.School thay vì chỉ type assertion như ví dụ rút gọn trên.
214
+
215
+ ## 4. Kiểm tra roster và cấp quyền
216
+
217
+ Backend OE.School phải là nơi quyết định quyền truy cập. Luồng đề xuất:
218
+
219
+ ```typescript
220
+ async function getLessonMeetingAccess(input: {
221
+ lessonId: string;
222
+ currentUserId: string;
223
+ }) {
224
+ const lesson = await lessonRepository.findByIdOrFail(input.lessonId);
225
+ const attendance = await attendanceRepository.findByLessonAndUser(
226
+ input.lessonId,
227
+ input.currentUserId,
228
+ );
229
+
230
+ const canJoinAsTeacher = lesson.teacherId === input.currentUserId;
231
+ const canJoinAsStudent =
232
+ attendance?.isEnrolled === true && attendance.isAbsent !== true;
233
+
234
+ if (!canJoinAsTeacher && !canJoinAsStudent) {
235
+ throw new ForbiddenException('User cannot join this lesson');
236
+ }
237
+
238
+ const room = await getOrCreateMeetingRoom(lesson);
239
+ const { token } = await oeMeetingApi.createJoinToken({
240
+ roomSlug: room.slug,
241
+ userId: input.currentUserId,
242
+ displayName: attendance?.displayName ?? lesson.teacherName,
243
+ });
244
+
245
+ return { roomSlug: room.slug, joinToken: token };
246
+ }
247
+ ```
248
+
249
+ Các trạng thái roster phải dùng enum/constant của OE.School. Ví dụ trên dùng
250
+ boolean chỉ để minh họa. Nếu học sinh bị đánh dấu nghỉ sau khi đã nhận token,
251
+ backend phải ngừng cấp token mới; để thu hồi ngay một token đã cấp cần bổ sung
252
+ cơ chế remove participant/token TTL ở phiên bản tiếp theo.
253
+
254
+ ## 5. API OE.School dành cho frontend
255
+
256
+ Nên tạo một endpoint cùng origin với frontend OE.School:
257
+
258
+ ```http
259
+ GET /api/lessons/:lessonId/meeting-access
260
+ Authorization: Bearer <oe-school-session>
261
+ ```
262
+
263
+ Response:
264
+
265
+ ```json
266
+ {
267
+ "roomSlug": "oe-school-lesson-98765",
268
+ "joinToken": "<livekit-join-token>"
269
+ }
270
+ ```
271
+
272
+ Endpoint này phải:
273
+
274
+ 1. Xác thực session OE.School.
275
+ 2. Tìm lesson theo `lessonId`.
276
+ 3. Kiểm tra giáo viên/học sinh có quyền vào đúng buổi học.
277
+ 4. Tạo phòng nếu chưa tồn tại.
278
+ 5. Cấp join token cho chính user đang đăng nhập.
279
+ 6. Không cache response token trong shared cache/CDN.
280
+
281
+ Khuyến nghị response headers:
282
+
283
+ ```http
284
+ Cache-Control: no-store
285
+ Pragma: no-cache
286
+ ```
287
+
@@ -0,0 +1,295 @@
1
+ # Tích hợp React SDK vào OE.School
2
+
3
+ ## 1. Cài đặt
4
+
5
+ Package yêu cầu React 17 trở lên. Trong giai đoạn package chưa publish lên
6
+ registry, OE.School cài bằng local path hoặc workspace.
7
+
8
+ ### Package registry (sau khi publish)
9
+
10
+ ```bash
11
+ npm install @tinyweb_dev/oe-meeting-react
12
+ ```
13
+
14
+ ### Local package
15
+
16
+ Build package tại repository OE Meeting:
17
+
18
+ ```bash
19
+ cd src/sdk/react
20
+ npm install
21
+ npm run build
22
+ ```
23
+
24
+ Sau đó cài package local trong dự án OE.School:
25
+
26
+ ```bash
27
+ npm install ../oe-meeting/src/sdk/react
28
+ ```
29
+
30
+ Nếu hai dự án thuộc cùng monorepo, có thể khai báo
31
+ `"@tinyweb_dev/oe-meeting-react": "workspace:*"`.
32
+
33
+ ## 2. Nhúng phòng bằng React
34
+
35
+ ```tsx
36
+ 'use client';
37
+
38
+ import { useEffect, useState } from 'react';
39
+ import {
40
+ EmbedErrorCode,
41
+ OeMeeting,
42
+ type OeMeetingProps,
43
+ } from '@tinyweb_dev/oe-meeting-react';
44
+
45
+ interface MeetingAccess {
46
+ roomSlug: string;
47
+ joinToken: string;
48
+ }
49
+
50
+ export function OnlineLesson({ lessonId }: { lessonId: string }) {
51
+ const [access, setAccess] = useState<MeetingAccess | null>(null);
52
+
53
+ useEffect(() => {
54
+ const controller = new AbortController();
55
+
56
+ void fetch(`/api/lessons/${lessonId}/meeting-access`, {
57
+ credentials: 'include',
58
+ cache: 'no-store',
59
+ signal: controller.signal,
60
+ })
61
+ .then(async (response) => {
62
+ if (!response.ok) {
63
+ throw new Error(`Cannot get meeting access: ${response.status}`);
64
+ }
65
+ return (await response.json()) as MeetingAccess;
66
+ })
67
+ .then(setAccess)
68
+ .catch((error: unknown) => {
69
+ if (error instanceof DOMException && error.name === 'AbortError') return;
70
+ console.error(error);
71
+ });
72
+
73
+ return () => controller.abort();
74
+ }, [lessonId]);
75
+
76
+ if (!access) return <div>Đang tải phòng học…</div>;
77
+
78
+ const onError: NonNullable<OeMeetingProps['onError']> = (event) => {
79
+ switch (event.payload.code) {
80
+ case EmbedErrorCode.TOKEN_EXPIRED:
81
+ case EmbedErrorCode.TOKEN_INVALID:
82
+ // Gọi lại meeting-access để lấy token mới.
83
+ break;
84
+ case EmbedErrorCode.ROOM_ACCESS_DENIED:
85
+ // Hiển thị thông báo không thuộc danh sách tham dự.
86
+ break;
87
+ default:
88
+ console.error(event.payload.message);
89
+ }
90
+ };
91
+
92
+ return (
93
+ <OeMeeting
94
+ roomSlug={access.roomSlug}
95
+ joinToken={access.joinToken}
96
+ origin="https://meet.oceanedu.org"
97
+ width="100%"
98
+ height="calc(100vh - 64px)"
99
+ hideLeave
100
+ onReady={(event) => console.info('Meeting ready', event.payload)}
101
+ onJoined={(event) => console.info('User joined', event.payload)}
102
+ onLeft={(event) => console.info('User left', event.payload)}
103
+ onError={onError}
104
+ />
105
+ );
106
+ }
107
+ ```
108
+
109
+ `origin` có giá trị mặc định là `https://meet.oceanedu.org`, nên có thể bỏ prop
110
+ này ở production. Khi chạy local, truyền URL frontend OE Meeting local, ví dụ
111
+ `http://localhost:5173`.
112
+
113
+ ## 3. Props của `OeMeeting`
114
+
115
+ | Prop | Type | Mặc định | Mô tả |
116
+ |---|---|---|---|
117
+ | `roomSlug` | `string` | Bắt buộc | Slug phòng do backend trả về. |
118
+ | `joinToken` | `string` | Bắt buộc | Token cấp riêng cho user hiện tại. |
119
+ | `origin` | `string` | `https://meet.oceanedu.org` | Origin của OE Meeting. |
120
+ | `autoJoin` | `boolean` | `true` | Phiên bản hiện tại chỉ hỗ trợ `true`. |
121
+ | `hideLeave` | `boolean` | `false` | Ẩn nút rời phòng trong iframe. |
122
+ | `userName` | `string` | — | Thông tin tùy chọn truyền vào embed URL. |
123
+ | `externalUserId` | `string` | — | External identity tùy chọn. |
124
+ | `lang` | `string` | — | Ngôn ngữ giao diện. |
125
+ | `title` | `string` | `OE Meeting room` | Accessible title của iframe. |
126
+ | `width` | `number \| string` | `100%` | Chiều rộng wrapper. Number được đổi sang px. |
127
+ | `height` | `number \| string` | `640` | Chiều cao wrapper. Number được đổi sang px. |
128
+ | `className` | `string` | — | Class của wrapper. |
129
+ | `style` | `CSSProperties` | — | Style của wrapper. |
130
+ | `iframeClassName` | `string` | — | Class của iframe. |
131
+ | `iframeStyle` | `CSSProperties` | — | Style của iframe. |
132
+ | `allow` | `string` | Camera/microphone/fullscreen… | Iframe Permissions Policy. |
133
+ | `targetOrigin` | `string` | Origin suy ra từ `origin` | Chỉ override khi có proxy đặc biệt. |
134
+ | `onReady` | callback | — | Embed bridge đã sẵn sàng. |
135
+ | `onJoined` | callback | — | Local user đã vào phòng. |
136
+ | `onLeft` | callback | — | Local user đã rời phòng. |
137
+ | `onParticipantJoined` | callback | — | Một participant khác vào phòng. |
138
+ | `onParticipantLeft` | callback | — | Một participant khác rời phòng. |
139
+ | `onEnded` | callback | — | Phòng kết thúc. |
140
+ | `onError` | callback | — | Lỗi typed từ embed. |
141
+ | `onEvent` | callback | — | Nhận tất cả event protocol. |
142
+
143
+ Không đặt `targetOrigin="*"`. SDK và iframe đều kiểm tra origin, source và
144
+ `instanceId` trước khi xử lý message.
145
+
146
+ ## 4. Events
147
+
148
+ Mọi callback nhận object có envelope:
149
+
150
+ ```typescript
151
+ interface EmbedEventEnvelope<TPayload> {
152
+ source: 'oemeeting';
153
+ version: 1;
154
+ instanceId: string;
155
+ type: string;
156
+ payload: TPayload;
157
+ }
158
+ ```
159
+
160
+ | Event | Payload chính | Trạng thái v1 |
161
+ |---|---|---|
162
+ | `READY` | `roomSlug` | Đã emit |
163
+ | `JOINED` | `roomSlug`, `identity`, `name?`, `joinedAt` | Đã emit |
164
+ | `LEFT` | `roomSlug`, `identity`, `leftAt`, `durationMs?` | Đã emit |
165
+ | `PARTICIPANT_JOINED` | `identity`, `name?`, `participantCount` | Đã emit |
166
+ | `PARTICIPANT_LEFT` | `identity`, `participantCount` | Đã emit |
167
+ | `ERROR` | `code`, `message` | Đã emit |
168
+ | `ENDED` | `roomSlug`, `endedAt` | Đã khai báo, chưa có nguồn emit |
169
+
170
+ Có thể dùng `onEvent` để gửi analytics tập trung, nhưng không log join token,
171
+ iframe URL đầy đủ hoặc dữ liệu nhạy cảm.
172
+
173
+ ## 5. Điều khiển phòng từ trang cha
174
+
175
+ ```tsx
176
+ import { useRef } from 'react';
177
+ import {
178
+ OeMeeting,
179
+ type OeMeetingHandle,
180
+ } from '@tinyweb_dev/oe-meeting-react';
181
+
182
+ export function MeetingWithControls(props: {
183
+ roomSlug: string;
184
+ joinToken: string;
185
+ }) {
186
+ const meetingRef = useRef<OeMeetingHandle>(null);
187
+
188
+ return (
189
+ <>
190
+ <button onClick={() => meetingRef.current?.toggleMic()}>
191
+ Bật/tắt micro
192
+ </button>
193
+ <button onClick={() => meetingRef.current?.toggleCamera()}>
194
+ Bật/tắt camera
195
+ </button>
196
+ <button onClick={() => meetingRef.current?.leave()}>
197
+ Rời phòng
198
+ </button>
199
+
200
+ <OeMeeting
201
+ ref={meetingRef}
202
+ roomSlug={props.roomSlug}
203
+ joinToken={props.joinToken}
204
+ hideLeave
205
+ />
206
+ </>
207
+ );
208
+ }
209
+ ```
210
+
211
+ `OeMeetingHandle` cung cấp:
212
+
213
+ - `leave()`.
214
+ - `toggleMic()`.
215
+ - `toggleCamera()`.
216
+ - `getIframe()`.
217
+
218
+ ## 6. Error codes
219
+
220
+ | Code | Ý nghĩa | Xử lý đề xuất | Trạng thái v1 |
221
+ |---|---|---|---|
222
+ | `CONNECTION_FAILED` | Không kết nối được LiveKit/config/token. | Lấy token mới một lần; nếu vẫn lỗi thì cho retry với backoff. | Đã emit |
223
+ | `UNKNOWN` | Command từ trang cha thất bại. | Ghi telemetry đã loại dữ liệu nhạy cảm. | Đã emit |
224
+ | `TOKEN_INVALID` | Token không hợp lệ. | Xóa token hiện tại, gọi lại API meeting-access. | Đã khai báo, chưa phân loại riêng |
225
+ | `TOKEN_EXPIRED` | Token hết hạn. | Xin token mới sau khi kiểm tra roster lại. | Đã khai báo, chưa phân loại riêng |
226
+ | `ROOM_NOT_FOUND` | Phòng không tồn tại. | Tạo/đồng bộ lại phòng của lesson. | Đã khai báo, chưa phân loại riêng |
227
+ | `ROOM_ACCESS_DENIED` | User không có quyền. | Không retry tự động; hiển thị thông báo phù hợp. | Đã khai báo, chưa phân loại riêng |
228
+ | `PROTOCOL_MISMATCH` | SDK và embed khác version. | Đồng bộ phiên bản SDK/OE Meeting. | Đã khai báo, chưa emit |
229
+
230
+ ## 7. Local development
231
+
232
+ Giả sử OE Meeting frontend chạy tại `http://localhost:5173`:
233
+
234
+ ```tsx
235
+ <OeMeeting
236
+ origin="http://localhost:5173"
237
+ roomSlug={roomSlug}
238
+ joinToken={joinToken}
239
+ />
240
+ ```
241
+
242
+ External API local phải trỏ vào backend OE Meeting local. Credentials local và
243
+ production phải tách biệt. Theo quy ước dự án:
244
+
245
+ - `.env`: local.
246
+ - `.env.dev`: dev.
247
+ - `.env.prod`: production.
248
+
249
+ Khi test iframe, hai ứng dụng có thể chạy trên hai port khác nhau; SDK tự truyền
250
+ `window.location.origin` của OE.School làm `hostOrigin` và bridge chỉ gửi message
251
+ về đúng origin đó.
252
+
253
+ ## 8. Checklist trước khi release
254
+
255
+ ### Backend OE.School
256
+
257
+ - [ ] Client secret chỉ tồn tại ở server/secret manager.
258
+ - [ ] Application có đủ `rooms:create` và `rooms:retrieve`.
259
+ - [ ] Room slug ổn định theo lesson và không chứa PII.
260
+ - [ ] Phòng được tạo với access level `restricted`.
261
+ - [ ] Mỗi lần cấp token đều kiểm tra session và roster.
262
+ - [ ] Học sinh nghỉ/không thuộc buổi học nhận HTTP 403.
263
+ - [ ] API meeting-access dùng `Cache-Control: no-store`.
264
+ - [ ] Không log token hoặc URL iframe có token.
265
+
266
+ ### Frontend OE.School
267
+
268
+ - [ ] Chỉ render `OeMeeting` sau khi nhận `roomSlug` và `joinToken`.
269
+ - [ ] Production dùng origin `https://meet.oceanedu.org`.
270
+ - [ ] Có trạng thái loading, forbidden, token expired và connection failed.
271
+ - [ ] Không dùng `targetOrigin="*"`.
272
+ - [ ] Camera/microphone hoạt động trong iframe.
273
+ - [ ] Event join/leave được gửi analytics nếu nghiệp vụ yêu cầu.
274
+
275
+ ### Kiểm thử nghiệp vụ
276
+
277
+ - [ ] Giáo viên tạo/mở phòng từ trang buổi học.
278
+ - [ ] Học sinh có trong roster vào được phòng.
279
+ - [ ] Học sinh nghỉ hoặc không thuộc roster không nhận được token.
280
+ - [ ] Người có link nhưng không có token không vào được phòng.
281
+ - [ ] Reload trang cấp token mới và kết nối lại thành công.
282
+ - [ ] Nhiều lần tạo cùng lesson không sinh phòng trùng.
283
+ - [ ] Nút rời phòng, micro và camera từ trang cha hoạt động.
284
+
285
+ ## 9. Giới hạn phiên bản hiện tại
286
+
287
+ - Join token đang được truyền trong iframe query string. Không log URL đầy đủ và
288
+ không đưa URL này vào analytics. Iframe dùng referrer policy hạn chế rò rỉ URL.
289
+ - OE Meeting chưa đồng bộ roster vào database; OE.School chịu trách nhiệm duyệt
290
+ quyền trước mỗi lần cấp token.
291
+ - Chưa có API thu hồi token đã cấp hoặc kick participant theo lesson attendance.
292
+ - Protocol v1 chưa có recording events và token handshake qua `postMessage`.
293
+ - `autoJoin={false}` chưa có prejoin flow; phiên bản hiện tại yêu cầu auto join.
294
+ - Các lỗi token/access chưa được phân loại riêng; frontend có thể nhận
295
+ `CONNECTION_FAILED` cho các lỗi kết nối liên quan token.