@noverachat/sdk-web 0.0.3 → 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.
@@ -19,6 +19,7 @@ import type {
19
19
  InviteToken,
20
20
  MessageIdStr,
21
21
  MessageOut,
22
+ RoomMemberOut,
22
23
  RoomUnread,
23
24
  SendFileOptions,
24
25
  SendParams,
@@ -34,67 +35,63 @@ import type {
34
35
  } from "../types.js";
35
36
  import { EventBus } from "../internal/event-bus.js";
36
37
  import { tempId as newTempId } from "../internal/temp-id.js";
38
+ import {
39
+ parseAnnouncementOut,
40
+ parseBulkDeleteBySenderResult,
41
+ parseBulkDeleteResult,
42
+ parseFileCreateResponse,
43
+ parseFileOut,
44
+ parseInviteTokenOut,
45
+ parseMessageOut,
46
+ parseRoomMemberOut,
47
+ parseRoomUnread,
48
+ } from "../generated/rest.js";
37
49
  // Note: previously used `utils/debounce`, replaced with a hand-rolled
38
50
  // timer in Room so we can also expose `flushMarkRead()` — the util has
39
51
  // no flush primitive.
40
52
 
41
- // Shape coming off the wire from /api/v1/rooms/{id}/messages backend is
42
- // Pydantic v2 with snake_case AND serializes 63-bit ids as JSON strings
43
- // (see `MsgId` on the server). Kept private to this module; callers see
44
- // the camelCase MessageOut exported from types.ts with string ids.
45
- interface RawMessageOut {
46
- app_id: string;
47
- room_id: string;
48
- message_id: string;
49
- sender: { user_id: string; nickname?: string | null; profile_image_url?: string | null } | null;
50
- sender_id: string | null;
51
- message_type: MessageOut["messageType"];
52
- custom_type?: string | null;
53
- content?: string | null;
54
- data?: Record<string, unknown> | null;
55
- meta?: Record<string, unknown> | null;
56
- file_id?: string | null;
57
- file?: {
58
- id: string;
59
- kind: "image" | "video" | "file";
60
- mime: string;
61
- size: number;
62
- name?: string | null;
63
- width?: number | null;
64
- height?: number | null;
65
- duration_sec?: number | null;
66
- thumbnail_status: "pending" | "ready" | "failed" | "skipped";
67
- forward_blocked?: boolean;
68
- } | null;
69
- files?: Array<{
70
- id: string;
71
- kind: "image" | "video" | "file";
72
- mime: string;
73
- size: number;
74
- name?: string | null;
75
- width?: number | null;
76
- height?: number | null;
77
- duration_sec?: number | null;
78
- thumbnail_status: "pending" | "ready" | "failed" | "skipped";
79
- forward_blocked?: boolean;
80
- }> | null;
81
- reply_to_id?: string | null;
82
- reactions?: Array<{ key: string; user_ids: string[]; updated_at: number }>;
83
- created_at: string;
84
- created_at_ms: number;
85
- updated_at?: string | null;
86
- updated_at_ms?: number | null;
87
- deleted_at?: string | null;
88
- deleted_by_user_id?: string | null;
89
- delete_scope?: MessageOut["deleteScope"] | null;
90
- edited_count?: number;
53
+ // Inline file metadata as it rides on a message (chat_receive / REST
54
+ // history). The generated REST `MessageOut` leaves `file`/`files` free-form
55
+ // (the OpenAPI schema does), so we fold the snake_case wire shape down to
56
+ // the camelCase `FileInline` here mirrors `FileInlineWire` in wire.ts.
57
+ function toFileInline(f: {
58
+ id: string;
59
+ kind: FileKind;
60
+ mime: string;
61
+ size: number;
62
+ name?: string | null;
63
+ width?: number | null;
64
+ height?: number | null;
65
+ duration_sec?: number | null;
66
+ thumbnail_status: FileMeta["thumbnailStatus"];
67
+ forward_blocked?: boolean;
68
+ }): FileMeta {
69
+ return {
70
+ id: f.id,
71
+ kind: f.kind,
72
+ mime: f.mime,
73
+ size: f.size,
74
+ name: f.name ?? null,
75
+ width: f.width ?? null,
76
+ height: f.height ?? null,
77
+ durationSec: f.duration_sec ?? null,
78
+ thumbnailStatus: f.thumbnail_status,
79
+ forwardBlocked: Boolean(f.forward_blocked),
80
+ };
91
81
  }
92
82
 
93
- function toMessageOut(r: RawMessageOut): MessageOut {
94
- // Phase D #11 extract sender-attached `_customMeta` from `data`.
95
- // Backend stores the whole `data` blob untouched, so both self-sent
96
- // and received messages surface `customMeta` here for the caller.
97
- const dataObj = r.data ?? null;
83
+ // Enhance the GENERATED `parseMessageOut` with the two client-side
84
+ // extras it can't derive from the OpenAPI schema: parse `file`/`files`
85
+ // into the camelCase `FileInline` shape, and lift sender-attached
86
+ // `_customMeta` out of `data` (Phase D #11). Everything else — key
87
+ // renames, nested sender/reactions comes straight from the codegen.
88
+ function toMessageOut(j: unknown): MessageOut {
89
+ const base = parseMessageOut(j);
90
+ const raw = j as {
91
+ file?: Parameters<typeof toFileInline>[0] | null;
92
+ files?: Parameters<typeof toFileInline>[0][] | null;
93
+ };
94
+ const dataObj = base.data ?? null;
98
95
  const customMeta =
99
96
  dataObj && typeof dataObj === "object" && "_customMeta" in dataObj
100
97
  ? ((dataObj as Record<string, unknown>)._customMeta as
@@ -102,66 +99,12 @@ function toMessageOut(r: RawMessageOut): MessageOut {
102
99
  | null)
103
100
  : null;
104
101
  return {
105
- appId: r.app_id,
106
- roomId: r.room_id,
107
- messageId: r.message_id,
108
- sender: r.sender
109
- ? {
110
- userId: r.sender.user_id,
111
- nickname: r.sender.nickname ?? null,
112
- profileImageUrl: r.sender.profile_image_url ?? null,
113
- }
114
- : null,
115
- senderId: r.sender_id ?? null,
116
- messageType: r.message_type,
117
- customType: r.custom_type ?? null,
118
- content: r.content ?? null,
119
- data: dataObj,
120
- meta: r.meta ?? null,
102
+ ...base,
103
+ file: raw.file ? toFileInline(raw.file) : null,
104
+ files:
105
+ raw.files && raw.files.length > 0 ? raw.files.map(toFileInline) : null,
106
+ reactions: base.reactions ?? [],
121
107
  customMeta,
122
- fileId: r.file_id ?? null,
123
- file: r.file
124
- ? {
125
- id: r.file.id,
126
- kind: r.file.kind,
127
- mime: r.file.mime,
128
- size: r.file.size,
129
- name: r.file.name ?? null,
130
- width: r.file.width ?? null,
131
- height: r.file.height ?? null,
132
- durationSec: r.file.duration_sec ?? null,
133
- thumbnailStatus: r.file.thumbnail_status,
134
- forwardBlocked: Boolean(r.file.forward_blocked),
135
- }
136
- : null,
137
- files: r.files && r.files.length > 0
138
- ? r.files.map((f) => ({
139
- id: f.id,
140
- kind: f.kind,
141
- mime: f.mime,
142
- size: f.size,
143
- name: f.name ?? null,
144
- width: f.width ?? null,
145
- height: f.height ?? null,
146
- durationSec: f.duration_sec ?? null,
147
- thumbnailStatus: f.thumbnail_status,
148
- forwardBlocked: Boolean(f.forward_blocked),
149
- }))
150
- : null,
151
- replyToId: r.reply_to_id ?? null,
152
- reactions: (r.reactions ?? []).map((x) => ({
153
- key: x.key,
154
- userIds: x.user_ids,
155
- updatedAt: x.updated_at,
156
- })),
157
- createdAt: r.created_at,
158
- createdAtMs: r.created_at_ms,
159
- updatedAt: r.updated_at ?? null,
160
- updatedAtMs: r.updated_at_ms ?? null,
161
- deletedAt: r.deleted_at ?? null,
162
- deletedByUserId: r.deleted_by_user_id ?? null,
163
- deleteScope: r.delete_scope ?? null,
164
- editedCount: r.edited_count ?? 0,
165
108
  };
166
109
  }
167
110
 
@@ -446,25 +389,24 @@ export class Room {
446
389
  // Step 1 — presign. The backend assigns the file_id and the S3 key
447
390
  // and returns the URL we should PUT to. ``Content-Type`` is bound
448
391
  // into the signature, so the PUT must echo it.
449
- const presign = await this.http.request<{
450
- file_id: MessageIdStr;
451
- upload_url: string;
452
- headers: Record<string, string>;
453
- expires_at_ms: number;
454
- }>("POST", "/api/v1/files", {
455
- kind,
456
- mime: file.type || "application/octet-stream",
457
- size: file.size,
458
- original_name: name,
459
- // 파일 전달 제한 — uploader-set at presign time. When true,
460
- // other users can't forward this file to a different room.
461
- forward_blocked: Boolean(opts.forwardBlocked),
462
- });
392
+ const created = parseFileCreateResponse(
393
+ await this.http.request<unknown>("POST", "/api/v1/files", {
394
+ kind,
395
+ mime: file.type || "application/octet-stream",
396
+ size: file.size,
397
+ original_name: name,
398
+ // 파일 전달 제한 — uploader-set at presign time. When true,
399
+ // other users can't forward this file to a different room.
400
+ forward_blocked: Boolean(opts.forwardBlocked),
401
+ }),
402
+ );
463
403
  const presigned: FilePresignResponse = {
464
- fileId: presign.file_id,
465
- uploadUrl: presign.upload_url,
466
- headers: presign.headers,
467
- expiresAtMs: presign.expires_at_ms,
404
+ fileId: created.fileId,
405
+ uploadUrl: created.uploadUrl,
406
+ // Presigned S3 headers are always string-valued; the generated
407
+ // model types the JSON object loosely as Record<string, unknown>.
408
+ headers: created.headers as Record<string, string>,
409
+ expiresAtMs: created.expiresAtMs,
468
410
  };
469
411
 
470
412
  // Step 2 — S3 PUT via XHR so the caller can show a progress bar.
@@ -542,24 +484,21 @@ export class Room {
542
484
  : "file");
543
485
  const name = opts.name ?? file.name;
544
486
 
545
- const presign = await this.http.request<{
546
- file_id: MessageIdStr;
547
- upload_url: string;
548
- headers: Record<string, string>;
549
- expires_at_ms: number;
550
- }>("POST", "/api/v1/files", {
551
- kind,
552
- mime: file.type || "application/octet-stream",
553
- size: file.size,
554
- original_name: name,
555
- forward_blocked: Boolean(opts.forwardBlocked),
556
- });
487
+ const created = parseFileCreateResponse(
488
+ await this.http.request<unknown>("POST", "/api/v1/files", {
489
+ kind,
490
+ mime: file.type || "application/octet-stream",
491
+ size: file.size,
492
+ original_name: name,
493
+ forward_blocked: Boolean(opts.forwardBlocked),
494
+ }),
495
+ );
557
496
 
558
497
  await new Promise<void>((resolve, reject) => {
559
498
  const xhr = new XMLHttpRequest();
560
- xhr.open("PUT", presign.upload_url);
561
- for (const [k, v] of Object.entries(presign.headers)) {
562
- xhr.setRequestHeader(k, v);
499
+ xhr.open("PUT", created.uploadUrl);
500
+ for (const [k, v] of Object.entries(created.headers)) {
501
+ xhr.setRequestHeader(k, v as string);
563
502
  }
564
503
  if (opts.onProgress) {
565
504
  xhr.upload.addEventListener("progress", (e) => {
@@ -585,9 +524,9 @@ export class Room {
585
524
  });
586
525
 
587
526
  await this.http.request<FileMeta>(
588
- "POST", `/api/v1/files/${presign.file_id}/commit`, {},
527
+ "POST", `/api/v1/files/${created.fileId}/commit`, {},
589
528
  );
590
- return presign.file_id;
529
+ return created.fileId;
591
530
  }
592
531
 
593
532
  /**
@@ -619,18 +558,7 @@ export class Room {
619
558
  message_id: MessageIdStr;
620
559
  sender_id: string | null;
621
560
  created_at_ms: number;
622
- file: {
623
- id: MessageIdStr;
624
- kind: FileKind;
625
- mime: string;
626
- size_bytes: number;
627
- original_name: string | null;
628
- width: number | null;
629
- height: number | null;
630
- duration_sec: number | null;
631
- thumbnail_status: FileMeta["thumbnailStatus"];
632
- forward_blocked?: boolean;
633
- };
561
+ file: unknown;
634
562
  }>;
635
563
  next_cursor: MessageIdStr | null;
636
564
  has_more: boolean;
@@ -644,23 +572,29 @@ export class Room {
644
572
  },
645
573
  );
646
574
  return {
647
- items: raw.items.map((r) => ({
648
- messageId: r.message_id,
649
- senderId: r.sender_id,
650
- createdAtMs: r.created_at_ms,
651
- file: {
652
- id: r.file.id,
653
- kind: r.file.kind,
654
- mime: r.file.mime,
655
- size: r.file.size_bytes,
656
- name: r.file.original_name ?? null,
657
- width: r.file.width ?? null,
658
- height: r.file.height ?? null,
659
- durationSec: r.file.duration_sec ?? null,
660
- thumbnailStatus: r.file.thumbnail_status,
661
- forwardBlocked: Boolean(r.file.forward_blocked),
662
- },
663
- })),
575
+ items: raw.items.map((r) => {
576
+ // Per-file payload is the generated ``FileOut`` shape — parse it
577
+ // with the codegen'd converter, then fold to the leaner
578
+ // ``FileMeta`` (= FileInline) the SDK surfaces.
579
+ const f = parseFileOut(r.file);
580
+ return {
581
+ messageId: r.message_id,
582
+ senderId: r.sender_id,
583
+ createdAtMs: r.created_at_ms,
584
+ file: {
585
+ id: f.id,
586
+ kind: f.kind,
587
+ mime: f.mime,
588
+ size: f.sizeBytes,
589
+ name: f.originalName ?? null,
590
+ width: f.width ?? null,
591
+ height: f.height ?? null,
592
+ durationSec: f.durationSec ?? null,
593
+ thumbnailStatus: f.thumbnailStatus,
594
+ forwardBlocked: Boolean(f.forwardBlocked),
595
+ },
596
+ };
597
+ }),
664
598
  nextCursor: raw.next_cursor,
665
599
  hasMore: raw.has_more,
666
600
  };
@@ -706,9 +640,10 @@ export class Room {
706
640
  messageId: MessageIdStr,
707
641
  patch: { content?: string; data?: Record<string, unknown>; meta?: Record<string, unknown> },
708
642
  ): Promise<MessageOut> {
709
- return this.http.request<MessageOut>(
643
+ const raw = await this.http.request<unknown>(
710
644
  "PUT", `/api/v1/rooms/${this.roomId}/messages/${messageId}`, patch,
711
645
  );
646
+ return toMessageOut(raw);
712
647
  }
713
648
 
714
649
  async delete(messageId: MessageIdStr, scope: DeleteScope = "ALL"): Promise<void> {
@@ -733,11 +668,40 @@ export class Room {
733
668
  * Other members get a `MEMBER_LEFT` [`roomEvent`](../reference/noverachat.md#events).
734
669
  * Idempotent if already a non-member; throws 409 if the caller was KICKED
735
670
  * (ask an operator to restore first). For GROUP/SUPER_GROUP, re-entry needs
736
- * an operator add; for ONE, re-creating the 1:1 rejoins with history kept. */
737
- async leave(): Promise<void> {
671
+ * an operator add; for ONE, re-creating the 1:1 rejoins with history kept.
672
+ *
673
+ * Optional `deleteMessages` — atomically clean up chat history as part
674
+ * of leaving. Default is `"none"` (messages survive so the room's
675
+ * history keeps making sense to remaining members).
676
+ * - `"none"` — leave only.
677
+ * - `"mine"` — soft-delete every message YOU sent, then leave. Other
678
+ * members' messages are untouched. Broadcasts
679
+ * `messages_cleared_by_sender` so connected clients hide
680
+ * them locally.
681
+ * - `"all"` — wipe every message in the room, then leave. Subject to
682
+ * the app's `allow_member_bulk_delete` gate and per-room
683
+ * OPERATOR check — a plain member gets 403 back. */
684
+ async leave(options?: { deleteMessages?: "none" | "mine" | "all" }): Promise<void> {
685
+ const mode = options?.deleteMessages ?? "none";
686
+ const query = mode === "none"
687
+ ? ""
688
+ : `?delete_messages=${encodeURIComponent(mode)}`;
738
689
  await this.http.request<void>(
739
- "POST", `/api/v1/rooms/${this.roomId}/leave`,
690
+ "POST", `/api/v1/rooms/${this.roomId}/leave${query}`,
691
+ );
692
+ }
693
+
694
+ /** Delete every message YOU sent in this room. Standalone version of
695
+ * `leave({ deleteMessages: "mine" })` — useful when a user wants to
696
+ * scrub their history without actually leaving. Other members'
697
+ * messages are untouched; broadcasts `messages_cleared_by_sender`
698
+ * so connected clients hide the vanished ones locally.
699
+ * No OPERATOR gate — you own your messages. */
700
+ async deleteMyMessages(): Promise<{ cleared: number }> {
701
+ const raw = await this.http.request<unknown>(
702
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages/mine`,
740
703
  );
704
+ return parseBulkDeleteBySenderResult(raw);
741
705
  }
742
706
 
743
707
  /**
@@ -769,14 +733,10 @@ export class Room {
769
733
  cleared: number;
770
734
  upToMessageId: MessageIdStr | null;
771
735
  }> {
772
- const raw = await this.http.request<{
773
- cleared: number;
774
- up_to_message_id: string | null;
775
- }>("DELETE", `/api/v1/rooms/${this.roomId}/messages`);
776
- return {
777
- cleared: raw.cleared,
778
- upToMessageId: raw.up_to_message_id,
779
- };
736
+ const raw = await this.http.request<unknown>(
737
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages`,
738
+ );
739
+ return parseBulkDeleteResult(raw);
780
740
  }
781
741
 
782
742
  async react(messageId: MessageIdStr, key: string): Promise<void> {
@@ -801,7 +761,7 @@ export class Room {
801
761
  // camelCase contract here so callers (and the demo's renderMessage)
802
762
  // don't see `undefined` on `messageId` / `senderId` / etc.
803
763
  const raw = await this.http.request<{
804
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
764
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
805
765
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
806
766
  since: String(sinceMessageId), limit,
807
767
  });
@@ -824,7 +784,7 @@ export class Room {
824
784
  hasMore: boolean;
825
785
  }> {
826
786
  const raw = await this.http.request<{
827
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
787
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
828
788
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
829
789
  return {
830
790
  items: raw.items.map(toMessageOut),
@@ -845,7 +805,7 @@ export class Room {
845
805
  hasMore: boolean;
846
806
  }> {
847
807
  const raw = await this.http.request<{
848
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
808
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
849
809
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
850
810
  before: beforeMessageId, limit,
851
811
  });
@@ -881,7 +841,7 @@ export class Room {
881
841
  };
882
842
  if (opts?.before) params.before = opts.before;
883
843
  const raw = await this.http.request<{
884
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
844
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
885
845
  }>("GET", `/api/v1/rooms/${this.roomId}/messages/search`, undefined, params);
886
846
  return {
887
847
  items: raw.items.map(toMessageOut),
@@ -895,40 +855,11 @@ export class Room {
895
855
  * `room.deleteAllMessages()`. Server gates by membership for non-public
896
856
  * rooms, so the caller must already be a member to read this.
897
857
  */
898
- async listMembers(): Promise<Array<{
899
- userId: string;
900
- nickname: string | null;
901
- role: "OPERATOR" | "MEMBER" | "KICKED";
902
- pushTrigger: "ALL" | "MENTION" | "OFF";
903
- lastReadMessageId: MessageIdStr | null;
904
- joinedAt: string;
905
- /** Cluster-wide presence dot. `null` when the server didn't resolve it. */
906
- isOnline: boolean | null;
907
- /** Per-room mute expiry. `null` = not muted. A far-future sentinel
908
- * (year 9999) means the operator picked "indefinite" — clients
909
- * should render that as "무기한" instead of a real timestamp. */
910
- mutedUntil: string | null;
911
- }>> {
912
- const raw = await this.http.request<Array<{
913
- user_id: string;
914
- nickname?: string | null;
915
- role: "OPERATOR" | "MEMBER" | "KICKED";
916
- push_trigger: "ALL" | "MENTION" | "OFF";
917
- last_read_message_id: string | null;
918
- joined_at: string;
919
- is_online?: boolean | null;
920
- muted_until?: string | null;
921
- }>>("GET", `/api/v1/rooms/${this.roomId}/members`);
922
- return raw.map((m) => ({
923
- userId: m.user_id,
924
- nickname: m.nickname ?? null,
925
- role: m.role,
926
- pushTrigger: m.push_trigger,
927
- lastReadMessageId: m.last_read_message_id,
928
- joinedAt: m.joined_at,
929
- isOnline: m.is_online ?? null,
930
- mutedUntil: m.muted_until ?? null,
931
- }));
858
+ async listMembers(): Promise<RoomMemberOut[]> {
859
+ const raw = await this.http.request<unknown[]>(
860
+ "GET", `/api/v1/rooms/${this.roomId}/members`,
861
+ );
862
+ return raw.map((m) => parseRoomMemberOut(m));
932
863
  }
933
864
 
934
865
  // -------- mute --------
@@ -958,18 +889,10 @@ export class Room {
958
889
  * newer than the caller's `read_watermark`, plus the room's
959
890
  * `last_message_id` so the UI can show a "•" dot regardless of count. */
960
891
  async unread(): Promise<RoomUnread> {
961
- const raw = await this.http.request<{
962
- room_id: string;
963
- unread_count: number;
964
- last_read_message_id: string | null;
965
- last_message_id: string | null;
966
- }>("GET", `/api/v1/rooms/${this.roomId}/unread`);
967
- return {
968
- roomId: raw.room_id,
969
- unreadCount: raw.unread_count,
970
- lastReadMessageId: raw.last_read_message_id,
971
- lastMessageId: raw.last_message_id,
972
- };
892
+ const raw = await this.http.request<unknown>(
893
+ "GET", `/api/v1/rooms/${this.roomId}/unread`,
894
+ );
895
+ return parseRoomUnread(raw);
973
896
  }
974
897
 
975
898
  // -------- freeze --------
@@ -1007,80 +930,32 @@ export class Room {
1007
930
  // -------- announcements --------
1008
931
 
1009
932
  async listAnnouncements(): Promise<Announcement[]> {
1010
- const raw = await this.http.request<Array<{
1011
- id: number;
1012
- room_id: string;
1013
- content: string;
1014
- created_by: string;
1015
- created_at: string;
1016
- updated_at: string;
1017
- }>>("GET", `/api/v1/rooms/${this.roomId}/announcements`);
1018
- return raw.map((a) => ({
1019
- id: a.id,
1020
- roomId: a.room_id,
1021
- content: a.content,
1022
- createdBy: a.created_by,
1023
- createdAt: a.created_at,
1024
- updatedAt: a.updated_at,
1025
- }));
933
+ const raw = await this.http.request<unknown[]>(
934
+ "GET", `/api/v1/rooms/${this.roomId}/announcements`,
935
+ );
936
+ return raw.map((a) => parseAnnouncementOut(a));
1026
937
  }
1027
938
 
1028
939
  async getCurrentAnnouncement(): Promise<Announcement | null> {
1029
- const raw = await this.http.request<{
1030
- id: number;
1031
- room_id: string;
1032
- content: string;
1033
- created_by: string;
1034
- created_at: string;
1035
- updated_at: string;
1036
- } | null>("GET", `/api/v1/rooms/${this.roomId}/announcements/current`);
940
+ const raw = await this.http.request<unknown>(
941
+ "GET", `/api/v1/rooms/${this.roomId}/announcements/current`,
942
+ );
1037
943
  if (!raw) return null;
1038
- return {
1039
- id: raw.id,
1040
- roomId: raw.room_id,
1041
- content: raw.content,
1042
- createdBy: raw.created_by,
1043
- createdAt: raw.created_at,
1044
- updatedAt: raw.updated_at,
1045
- };
944
+ return parseAnnouncementOut(raw);
1046
945
  }
1047
946
 
1048
947
  async postAnnouncement(content: string): Promise<Announcement> {
1049
- const raw = await this.http.request<{
1050
- id: number;
1051
- room_id: string;
1052
- content: string;
1053
- created_by: string;
1054
- created_at: string;
1055
- updated_at: string;
1056
- }>("POST", `/api/v1/rooms/${this.roomId}/announcements`, { content });
1057
- return {
1058
- id: raw.id,
1059
- roomId: raw.room_id,
1060
- content: raw.content,
1061
- createdBy: raw.created_by,
1062
- createdAt: raw.created_at,
1063
- updatedAt: raw.updated_at,
1064
- };
948
+ const raw = await this.http.request<unknown>(
949
+ "POST", `/api/v1/rooms/${this.roomId}/announcements`, { content },
950
+ );
951
+ return parseAnnouncementOut(raw);
1065
952
  }
1066
953
 
1067
954
  async updateAnnouncement(id: number, content: string): Promise<Announcement> {
1068
- const raw = await this.http.request<{
1069
- id: number;
1070
- room_id: string;
1071
- content: string;
1072
- created_by: string;
1073
- created_at: string;
1074
- updated_at: string;
1075
- }>("PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content });
1076
- return {
1077
- id: raw.id,
1078
- roomId: raw.room_id,
1079
- content: raw.content,
1080
- createdBy: raw.created_by,
1081
- createdAt: raw.created_at,
1082
- updatedAt: raw.updated_at,
1083
- };
955
+ const raw = await this.http.request<unknown>(
956
+ "PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content },
957
+ );
958
+ return parseAnnouncementOut(raw);
1084
959
  }
1085
960
 
1086
961
  async deleteAnnouncement(id: number): Promise<void> {
@@ -1111,51 +986,19 @@ export class Room {
1111
986
  const body: Record<string, unknown> = {};
1112
987
  if (opts && "expiresInHours" in opts) body.expires_in_hours = opts.expiresInHours;
1113
988
  if (opts && "maxUses" in opts) body.max_uses = opts.maxUses;
1114
- const raw = await this.http.request<{
1115
- token: string;
1116
- room_id: string;
1117
- created_by: string;
1118
- created_at: string;
1119
- expires_at: string | null;
1120
- max_uses: number | null;
1121
- used_count: number;
1122
- revoked_at: string | null;
1123
- }>("POST", `/api/v1/rooms/${this.roomId}/invites`, body);
1124
- return {
1125
- token: raw.token,
1126
- roomId: raw.room_id,
1127
- createdBy: raw.created_by,
1128
- createdAt: raw.created_at,
1129
- expiresAt: raw.expires_at,
1130
- maxUses: raw.max_uses,
1131
- usedCount: raw.used_count,
1132
- revokedAt: raw.revoked_at,
1133
- };
989
+ const raw = await this.http.request<unknown>(
990
+ "POST", `/api/v1/rooms/${this.roomId}/invites`, body,
991
+ );
992
+ return parseInviteTokenOut(raw);
1134
993
  }
1135
994
 
1136
995
  /** OPERATOR-only: list all invite tokens for this room (including
1137
996
  * revoked / expired / exhausted), newest first. */
1138
997
  async listInvites(): Promise<InviteToken[]> {
1139
- const raw = await this.http.request<Array<{
1140
- token: string;
1141
- room_id: string;
1142
- created_by: string;
1143
- created_at: string;
1144
- expires_at: string | null;
1145
- max_uses: number | null;
1146
- used_count: number;
1147
- revoked_at: string | null;
1148
- }>>("GET", `/api/v1/rooms/${this.roomId}/invites`);
1149
- return raw.map((r) => ({
1150
- token: r.token,
1151
- roomId: r.room_id,
1152
- createdBy: r.created_by,
1153
- createdAt: r.created_at,
1154
- expiresAt: r.expires_at,
1155
- maxUses: r.max_uses,
1156
- usedCount: r.used_count,
1157
- revokedAt: r.revoked_at,
1158
- }));
998
+ const raw = await this.http.request<unknown[]>(
999
+ "GET", `/api/v1/rooms/${this.roomId}/invites`,
1000
+ );
1001
+ return raw.map((r) => parseInviteTokenOut(r));
1159
1002
  }
1160
1003
 
1161
1004
  /** OPERATOR-only: revoke a token immediately. Idempotent — revoking