@codingfactory/socialkit-vue 0.2.0 → 0.3.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,2767 @@
1
+ /**
2
+ * Configurable circles service for SocialKit-powered frontends.
3
+ */
4
+
5
+ import type { AxiosInstance } from 'axios'
6
+
7
+ export const CIRCLE_MANAGEMENT_SECTIONS = [
8
+ 'overview',
9
+ 'members',
10
+ 'requests',
11
+ 'content',
12
+ 'discussions',
13
+ 'events',
14
+ 'roles',
15
+ 'reports',
16
+ 'bans',
17
+ 'mutes',
18
+ 'audit',
19
+ 'automod',
20
+ 'settings',
21
+ ] as const
22
+
23
+ export type CircleManagementSection = typeof CIRCLE_MANAGEMENT_SECTIONS[number]
24
+ export type CircleVisibility = 'public' | 'closed' | 'secret'
25
+ export type CircleRole = 'owner' | 'admin' | 'moderator' | 'member'
26
+ export type CircleActorRole = CircleRole | null
27
+ export type CircleMembershipRole = 'owner' | 'member'
28
+ export type CircleJoinRequestStatus = 'pending' | 'approved' | 'rejected'
29
+ export type CircleReportStatus = 'pending' | 'actioned' | 'dismissed'
30
+ export type CircleReportDecision = 'action' | 'dismiss'
31
+ export type CircleModerationReportSource = 'member' | 'automod' | (string & {})
32
+ export type CircleModerationReportSubjectType = 'user' | CircleModerationSubjectType | (string & {})
33
+ export type CircleModerationSubjectType = 'post' | 'comment' | 'thread' | 'reply' | 'event'
34
+ export type CircleModerationSubjectState = 'active' | 'removed' | 'locked'
35
+ export type CircleModerationSubjectAction = 'remove' | 'restore' | 'lock' | 'unlock'
36
+ export type CirclePermissionKey =
37
+ | 'group.details.edit'
38
+ | 'group.settings.manage'
39
+ | 'group.visibility.manage'
40
+ | 'group.rules.manage'
41
+ | 'group.roles.manage'
42
+ | 'group.roles.assign'
43
+ | 'group.members.invite'
44
+ | 'group.members.remove'
45
+ | 'group.members.mute'
46
+ | 'group.members.unmute'
47
+ | 'group.members.ban'
48
+ | 'group.members.unban'
49
+ | 'group.requests.review'
50
+ | 'group.posts.review'
51
+ | 'group.posts.remove'
52
+ | 'group.posts.restore'
53
+ | 'group.comments.review'
54
+ | 'group.comments.remove'
55
+ | 'group.comments.restore'
56
+ | 'group.discussions.review'
57
+ | 'group.discussions.lock'
58
+ | 'group.discussions.unlock'
59
+ | 'group.discussions.remove'
60
+ | 'group.discussions.restore'
61
+ | 'group.events.manage'
62
+ | 'group.events.remove'
63
+ | 'group.events.restore'
64
+ | 'group.reports.review'
65
+ | 'group.audit.view'
66
+ | 'group.automod.manage'
67
+ | 'group.owner.transfer'
68
+ | (string & {})
69
+ export type CircleAutomodConditionType =
70
+ | 'keyword_match'
71
+ | 'regex'
72
+ | 'account_age'
73
+ | 'community_karma'
74
+ | 'link_domains'
75
+ | string
76
+ export type CircleAutomodActionType = 'remove' | 'flag' | 'approve' | 'add_flair' | string
77
+
78
+ type UnknownRecord = Record<string, unknown>
79
+
80
+ export interface CircleUserSummary {
81
+ id: string
82
+ name: string
83
+ handle: string
84
+ avatar: string | null
85
+ avatar_url: string | null
86
+ bio?: string | null
87
+ is_following?: boolean
88
+ is_followed_by?: boolean
89
+ }
90
+
91
+ export interface CircleActorCapabilities {
92
+ is_owner: boolean
93
+ is_moderator: boolean
94
+ moderation_level: CircleRole
95
+ permissions: CirclePermissionKey[]
96
+ can: Record<string, boolean>
97
+ canJoin: boolean
98
+ canLeave: boolean
99
+ canRequestToJoin: boolean
100
+ canInvite: boolean
101
+ canManage: boolean
102
+ canManageMembers: boolean
103
+ canManageRoles: boolean
104
+ canReviewRequests: boolean
105
+ canManageReports: boolean
106
+ canManageBans: boolean
107
+ canManageMutes: boolean
108
+ canViewAuditLog: boolean
109
+ canManageAutomod: boolean
110
+ canManageSettings: boolean
111
+ canTransferOwnership: boolean
112
+ canViewManagement: boolean
113
+ }
114
+
115
+ export interface CircleActorContext {
116
+ isMember: boolean
117
+ role: CircleActorRole
118
+ hasPendingRequest: boolean
119
+ capabilities: CircleActorCapabilities
120
+ managementSections: CircleManagementSection[]
121
+ }
122
+
123
+ export interface Circle {
124
+ id: string
125
+ name: string
126
+ slug: string
127
+ description?: string | null
128
+ visibility: CircleVisibility
129
+ is_hidden: boolean
130
+ owner_id?: string | null
131
+ meta: Record<string, unknown>
132
+ cover_photo_url?: string | null
133
+ cover_photo_media_id?: string | null
134
+ created_at: string
135
+ updated_at: string
136
+ is_member?: boolean
137
+ user_role?: CircleActorRole
138
+ member_count?: number
139
+ can_join?: boolean
140
+ can_manage?: boolean
141
+ can_manage_members?: boolean
142
+ can_invite?: boolean
143
+ can_leave?: boolean
144
+ can_request_to_join?: boolean
145
+ has_pending_request?: boolean
146
+ actor_capabilities?: Partial<CircleActorCapabilities>
147
+ management_sections?: CircleManagementSection[]
148
+ group_rules_summary?: string | null
149
+ welcome_message?: string | null
150
+ canonical_path?: string | null
151
+ actor?: CircleActorContext
152
+ creator?: CircleUserSummary
153
+ }
154
+
155
+ export interface CircleProfileOverrides {
156
+ name?: string
157
+ bio?: string
158
+ avatar_url?: string
159
+ }
160
+
161
+ export interface CircleMember {
162
+ id: string
163
+ user_id: string
164
+ circle_id: string
165
+ role: CircleRole
166
+ joined_at: string
167
+ membership_role?: CircleMembershipRole
168
+ display_role?: CircleRole
169
+ assigned_roles?: CircleRoleDefinition[]
170
+ effective_permissions?: CirclePermissionKey[]
171
+ is_muted?: boolean
172
+ muted_until?: string | null
173
+ is_banned?: boolean
174
+ can_remove?: boolean
175
+ can_change_role?: boolean
176
+ can_assign_roles?: boolean
177
+ can_mute?: boolean
178
+ can_unmute?: boolean
179
+ can_ban?: boolean
180
+ can_unban?: boolean
181
+ can_view_audit?: boolean
182
+ profile_overrides?: CircleProfileOverrides
183
+ user?: CircleUserSummary
184
+ }
185
+
186
+ export interface JoinRequest {
187
+ id: string
188
+ user_id: string
189
+ circle_id: string
190
+ status: CircleJoinRequestStatus
191
+ source: 'direct' | 'invite'
192
+ message?: string | null
193
+ reviewed_by_id?: string | null
194
+ reviewed_at?: string | null
195
+ created_at: string
196
+ updated_at: string
197
+ user?: CircleUserSummary
198
+ }
199
+
200
+ export interface InviteLink {
201
+ id: string
202
+ token: string
203
+ circle_id: string
204
+ target_user_id: string | null
205
+ max_uses: number | null
206
+ uses_count: number
207
+ expires_at: string | null
208
+ invite_url: string
209
+ qr_svg: string
210
+ }
211
+
212
+ export interface CircleInviteSearchUser {
213
+ id: string
214
+ name: string
215
+ handle: string
216
+ avatar_url: string | null
217
+ }
218
+
219
+ export interface CircleRoleDefinition {
220
+ id: string
221
+ slug: string
222
+ name: string
223
+ description: string | null
224
+ color: string | null
225
+ is_system: boolean
226
+ is_assignable: boolean
227
+ member_count: number
228
+ permissions: CirclePermissionKey[]
229
+ }
230
+
231
+ /**
232
+ * @deprecated Use CircleRoleDefinition. Kept only as a temporary compatibility export.
233
+ */
234
+ export interface CircleRoleAssignment extends CircleRoleDefinition {
235
+ id: string
236
+ user_id?: string
237
+ role?: CircleRole
238
+ assigned_at?: string | null
239
+ user?: CircleUserSummary
240
+ }
241
+
242
+ export interface CirclePermissionCatalogEntry {
243
+ key: CirclePermissionKey
244
+ label: string
245
+ description: string
246
+ module: string
247
+ owner_only: boolean
248
+ surfaced: boolean
249
+ section: CircleManagementSection
250
+ }
251
+
252
+ export interface CircleManagementCounts {
253
+ members: number
254
+ requests_pending: number
255
+ reports_pending: number
256
+ roles: number
257
+ bans_active: number
258
+ mutes_active: number
259
+ }
260
+
261
+ export interface CircleMemberRoleAssignmentResult {
262
+ assigned_roles: CircleRoleDefinition[]
263
+ effective_permissions: CirclePermissionKey[]
264
+ }
265
+
266
+ export interface CircleOwnershipTransferResult {
267
+ circle_id: string
268
+ previous_owner_id: string
269
+ new_owner_id: string
270
+ }
271
+
272
+ export interface CircleModerationReport {
273
+ id: string
274
+ reporter_id: string | null
275
+ source: CircleModerationReportSource
276
+ subject_user_id: string | null
277
+ subject_type: CircleModerationReportSubjectType | null
278
+ subject_id: string | null
279
+ subject_author_id: string | null
280
+ category: string
281
+ notes?: string | null
282
+ status: CircleReportStatus
283
+ priority: string
284
+ resolution_action?: string | null
285
+ created_at: string
286
+ reviewed_at?: string | null
287
+ resolution_notes?: string | null
288
+ }
289
+
290
+ export interface CircleBanRecord {
291
+ id: string
292
+ user_id: string
293
+ reason?: string | null
294
+ created_at: string
295
+ updated_at?: string | null
296
+ created_by_id?: string | null
297
+ user?: CircleUserSummary
298
+ }
299
+
300
+ export interface CircleMuteRecord {
301
+ id: string
302
+ user_id: string
303
+ reason?: string | null
304
+ muted_until?: string | null
305
+ created_at: string
306
+ updated_at?: string | null
307
+ created_by_id?: string | null
308
+ user?: CircleUserSummary
309
+ }
310
+
311
+ export interface CircleModerationAuditLogEntry {
312
+ id: string
313
+ action: string
314
+ actor_id: string
315
+ target_user_id: string | null
316
+ report_id: string | null
317
+ meta: Record<string, unknown>
318
+ created_at: string
319
+ }
320
+
321
+ export interface CircleModerationSubject {
322
+ subject_type: CircleModerationSubjectType
323
+ subject_id: string
324
+ circle_id: string
325
+ author_id: string | null
326
+ report_count: number
327
+ latest_report_status: CircleReportStatus | null
328
+ moderation_state: CircleModerationSubjectState
329
+ created_at: string
330
+ updated_at: string
331
+ preview: Record<string, unknown>
332
+ available_actions: CircleModerationSubjectAction[]
333
+ }
334
+
335
+ export interface CircleAutomodCondition {
336
+ type: CircleAutomodConditionType
337
+ config: Record<string, unknown>
338
+ }
339
+
340
+ export interface CircleAutomodAction {
341
+ type: CircleAutomodActionType
342
+ config: Record<string, unknown>
343
+ }
344
+
345
+ export interface CircleAutomodRule {
346
+ id: string
347
+ name: string
348
+ description?: string | null
349
+ is_enabled: boolean
350
+ conditions: CircleAutomodCondition[]
351
+ actions: CircleAutomodAction[]
352
+ priority: number
353
+ created_at: string
354
+ }
355
+
356
+ export interface CircleManagementBootstrap {
357
+ circle: Circle
358
+ actor: CircleActorContext
359
+ management_sections: CircleManagementSection[]
360
+ members: PaginationResponse<CircleMember>
361
+ requests: PaginationResponse<JoinRequest>
362
+ roles: PaginationResponse<CircleRoleDefinition>
363
+ counts: CircleManagementCounts
364
+ permission_catalog: CirclePermissionCatalogEntry[]
365
+ reports: PaginationResponse<CircleModerationReport>
366
+ bans: PaginationResponse<CircleBanRecord>
367
+ mutes: PaginationResponse<CircleMuteRecord>
368
+ audit: PaginationResponse<CircleModerationAuditLogEntry>
369
+ automod: PaginationResponse<CircleAutomodRule>
370
+ }
371
+
372
+ export interface CircleFilters {
373
+ visibility?: CircleVisibility
374
+ membership?: 'joined' | 'managed' | 'available'
375
+ search?: string
376
+ }
377
+
378
+ export interface PaginationResponse<T> {
379
+ data: T[]
380
+ meta: {
381
+ pagination: {
382
+ next_cursor: string | null
383
+ has_more: boolean
384
+ }
385
+ }
386
+ }
387
+
388
+ export interface CircleAutomodRuleInput {
389
+ name: string
390
+ description?: string | null
391
+ is_enabled?: boolean
392
+ conditions: CircleAutomodCondition[]
393
+ actions: CircleAutomodAction[]
394
+ priority?: number
395
+ }
396
+
397
+ export interface CircleCreateInput {
398
+ name: string
399
+ slug?: string
400
+ description?: string
401
+ visibility: CircleVisibility
402
+ is_hidden?: boolean
403
+ meta?: Record<string, unknown>
404
+ }
405
+
406
+ export interface CircleUpdateInput {
407
+ name?: string
408
+ slug?: string
409
+ description?: string | null
410
+ visibility?: CircleVisibility
411
+ is_hidden?: boolean
412
+ meta?: Record<string, unknown>
413
+ }
414
+
415
+ export interface CircleRoleDefinitionInput {
416
+ name: string
417
+ description?: string | null
418
+ color?: string | null
419
+ clone_from_role_id?: string | null
420
+ permissions?: CirclePermissionKey[]
421
+ }
422
+
423
+ export interface CircleRoleDefinitionUpdateInput {
424
+ name?: string
425
+ description?: string | null
426
+ color?: string | null
427
+ }
428
+
429
+ export interface CircleOwnershipTransferOptions {
430
+ note?: string | null
431
+ assign_previous_owner_admin?: boolean
432
+ }
433
+
434
+ export interface CircleBanMemberOptions {
435
+ reason?: string
436
+ }
437
+
438
+ export interface CircleMuteMemberOptions {
439
+ reason?: string
440
+ muted_until?: string
441
+ }
442
+
443
+ export interface CircleInviteLinkOptions {
444
+ max_uses?: number
445
+ expires_in_minutes?: number
446
+ target_user_id?: string
447
+ }
448
+
449
+ export interface CircleInviteLinkRequestOptions {
450
+ showSuccessToast?: boolean
451
+ }
452
+
453
+ export interface CirclesNotification {
454
+ description: string
455
+ color: 'success' | 'error' | 'warning'
456
+ duration?: number
457
+ }
458
+
459
+ export interface CircleTermOptions {
460
+ case?: 'title'
461
+ }
462
+
463
+ export interface CirclesServiceErrorContext {
464
+ error: unknown
465
+ url?: string
466
+ status?: number
467
+ timestamp: string
468
+ logLevel: 'warn' | 'error'
469
+ }
470
+
471
+ export interface CirclesServiceConfig {
472
+ client: AxiosInstance
473
+ sanitizeString?: (value: string) => string
474
+ onNotify?: (notification: CirclesNotification) => void
475
+ onUnauthorized?: () => void
476
+ getCircleTerm?: (options?: CircleTermOptions) => string
477
+ onError?: (context: CirclesServiceErrorContext) => void
478
+ }
479
+
480
+ type SanitizedInput<T> = T extends string
481
+ ? string
482
+ : T extends Array<infer U>
483
+ ? SanitizedInput<U>[]
484
+ : T extends Record<string, unknown>
485
+ ? { [K in keyof T]: SanitizedInput<T[K]> }
486
+ : T
487
+
488
+ interface ApiError {
489
+ config?: { url?: string }
490
+ response?: { status?: number; data?: { message?: string } }
491
+ }
492
+
493
+ const DEFAULT_LIST_LIMIT = '10'
494
+
495
+ function isApiError(value: unknown): value is ApiError {
496
+ return typeof value === 'object' && value !== null
497
+ }
498
+
499
+ function isRecord(value: unknown): value is UnknownRecord {
500
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
501
+ }
502
+
503
+ function readRecord(value: unknown, key: string): UnknownRecord | null {
504
+ if (!isRecord(value)) {
505
+ return null
506
+ }
507
+
508
+ const entry = value[key]
509
+ return isRecord(entry) ? entry : null
510
+ }
511
+
512
+ function readArray(value: unknown, key: string): unknown[] | null {
513
+ if (!isRecord(value)) {
514
+ return null
515
+ }
516
+
517
+ const entry = value[key]
518
+ return Array.isArray(entry) ? entry : null
519
+ }
520
+
521
+ function readString(value: unknown): string | null {
522
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
523
+ }
524
+
525
+ function readNullableString(value: unknown): string | null {
526
+ if (value === null || value === undefined) {
527
+ return null
528
+ }
529
+
530
+ return readString(value)
531
+ }
532
+
533
+ function readBoolean(value: unknown): boolean | null {
534
+ return typeof value === 'boolean' ? value : null
535
+ }
536
+
537
+ function readNumber(value: unknown): number | null {
538
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
539
+ }
540
+
541
+ function isCircleVisibility(value: unknown): value is CircleVisibility {
542
+ return value === 'public' || value === 'closed' || value === 'secret'
543
+ }
544
+
545
+ function isCircleRole(value: unknown): value is CircleRole {
546
+ return value === 'owner' || value === 'admin' || value === 'moderator' || value === 'member'
547
+ }
548
+
549
+ function isCircleMembershipRole(value: unknown): value is CircleMembershipRole {
550
+ return value === 'owner' || value === 'member'
551
+ }
552
+
553
+ function isCircleModerationReportSource(value: unknown): value is CircleModerationReportSource {
554
+ return value === 'member' || value === 'automod' || typeof value === 'string'
555
+ }
556
+
557
+ function isCircleModerationReportSubjectType(value: unknown): value is CircleModerationReportSubjectType {
558
+ return value === 'user' || value === 'post' || value === 'comment' || value === 'thread' || value === 'reply' || value === 'event' || typeof value === 'string'
559
+ }
560
+
561
+ function isManagementSection(value: unknown): value is CircleManagementSection {
562
+ return typeof value === 'string' && CIRCLE_MANAGEMENT_SECTIONS.includes(value as CircleManagementSection)
563
+ }
564
+
565
+ function createEmptyPagination<T>(): PaginationResponse<T> {
566
+ return {
567
+ data: [],
568
+ meta: {
569
+ pagination: {
570
+ next_cursor: null,
571
+ has_more: false,
572
+ },
573
+ },
574
+ }
575
+ }
576
+
577
+ function createEmptyManagementCounts(): CircleManagementCounts {
578
+ return {
579
+ members: 0,
580
+ requests_pending: 0,
581
+ reports_pending: 0,
582
+ roles: 0,
583
+ bans_active: 0,
584
+ mutes_active: 0,
585
+ }
586
+ }
587
+
588
+ function normalizeManagementSections(value: unknown): CircleManagementSection[] {
589
+ if (!Array.isArray(value)) {
590
+ return []
591
+ }
592
+
593
+ return value.filter((section): section is CircleManagementSection => isManagementSection(section))
594
+ }
595
+
596
+ function normalizePermissionKeys(value: unknown): CirclePermissionKey[] {
597
+ if (!Array.isArray(value)) {
598
+ return []
599
+ }
600
+
601
+ return value
602
+ .map((entry) => readString(entry))
603
+ .filter((entry): entry is CirclePermissionKey => entry !== null)
604
+ }
605
+
606
+ function normalizePermissionMap(value: unknown): Record<string, boolean> {
607
+ if (!isRecord(value)) {
608
+ return {}
609
+ }
610
+
611
+ const normalized: Record<string, boolean> = {}
612
+
613
+ for (const [key, entry] of Object.entries(value)) {
614
+ const flag = readBoolean(entry)
615
+ if (flag !== null) {
616
+ normalized[key] = flag
617
+ }
618
+ }
619
+
620
+ return normalized
621
+ }
622
+
623
+ function normalizeManagementCounts(value: unknown): CircleManagementCounts {
624
+ if (!isRecord(value)) {
625
+ return createEmptyManagementCounts()
626
+ }
627
+
628
+ return {
629
+ members: readNumber(value.members) ?? 0,
630
+ requests_pending: readNumber(value.requests_pending) ?? 0,
631
+ reports_pending: readNumber(value.reports_pending) ?? 0,
632
+ roles: readNumber(value.roles) ?? 0,
633
+ bans_active: readNumber(value.bans_active) ?? 0,
634
+ mutes_active: readNumber(value.mutes_active) ?? 0,
635
+ }
636
+ }
637
+
638
+ function normalizeUserSummary(value: unknown): CircleUserSummary | undefined {
639
+ if (!isRecord(value)) {
640
+ return undefined
641
+ }
642
+
643
+ const id = readString(value.id)
644
+ const name = readString(value.name)
645
+ const handle = readString(value.handle)
646
+
647
+ if (id === null || name === null || handle === null) {
648
+ return undefined
649
+ }
650
+
651
+ const avatar = readNullableString(value.avatar_url) ?? readNullableString(value.avatar)
652
+ const userSummary: CircleUserSummary = {
653
+ id,
654
+ name,
655
+ handle,
656
+ avatar,
657
+ avatar_url: avatar,
658
+ bio: readNullableString(value.bio),
659
+ }
660
+
661
+ const isFollowing = readBoolean(value.is_following)
662
+ if (isFollowing !== null) {
663
+ userSummary.is_following = isFollowing
664
+ }
665
+
666
+ const isFollowedBy = readBoolean(value.is_followed_by)
667
+ if (isFollowedBy !== null) {
668
+ userSummary.is_followed_by = isFollowedBy
669
+ }
670
+
671
+ return userSummary
672
+ }
673
+
674
+ function buildDefaultActorCapabilities(
675
+ role: CircleActorRole,
676
+ visibility: CircleVisibility,
677
+ isMember: boolean,
678
+ ): CircleActorCapabilities {
679
+ const canManage = role === 'owner' || role === 'admin'
680
+ const canManageRoles = role === 'owner'
681
+ const canReviewRequests = canManage && visibility !== 'public'
682
+
683
+ return {
684
+ is_owner: role === 'owner',
685
+ is_moderator: role === 'owner' || role === 'admin' || role === 'moderator',
686
+ moderation_level: role ?? 'member',
687
+ permissions: [],
688
+ can: {},
689
+ canJoin: !isMember && visibility === 'public',
690
+ canLeave: isMember && role !== 'owner',
691
+ canRequestToJoin: !isMember && visibility !== 'public',
692
+ canInvite: isMember,
693
+ canManage,
694
+ canManageMembers: canManage,
695
+ canManageRoles,
696
+ canReviewRequests,
697
+ canManageReports: canManage,
698
+ canManageBans: canManage,
699
+ canManageMutes: canManage,
700
+ canViewAuditLog: canManage,
701
+ canManageAutomod: canManage,
702
+ canManageSettings: canManage,
703
+ canTransferOwnership: role === 'owner',
704
+ canViewManagement: canManage,
705
+ }
706
+ }
707
+
708
+ function normalizeActorCapabilities(
709
+ value: unknown,
710
+ fallback: CircleActorCapabilities,
711
+ ): CircleActorCapabilities {
712
+ if (!isRecord(value)) {
713
+ return fallback
714
+ }
715
+
716
+ const permissionMap = normalizePermissionMap(value.can)
717
+ const permissions = normalizePermissionKeys(value.permissions)
718
+
719
+ const hasPermission = (permissionKey: string): boolean => {
720
+ if (typeof permissionMap[permissionKey] === 'boolean') {
721
+ return permissionMap[permissionKey]
722
+ }
723
+
724
+ return permissions.includes(permissionKey)
725
+ }
726
+
727
+ const hasAnyPermission = (permissionKeys: readonly string[]): boolean => {
728
+ return permissionKeys.some((permissionKey) => hasPermission(permissionKey))
729
+ }
730
+
731
+ const getFlag = (keys: readonly string[], defaultValue: boolean): boolean => {
732
+ for (const key of keys) {
733
+ const flag = readBoolean(value[key])
734
+ if (flag !== null) {
735
+ return flag
736
+ }
737
+ }
738
+
739
+ return defaultValue
740
+ }
741
+
742
+ const isOwner = getFlag(['isOwner', 'is_owner'], false)
743
+ const isModerator = getFlag(['isModerator', 'is_moderator'], false)
744
+ const moderationLevel = isCircleRole(value.moderation_level)
745
+ ? value.moderation_level
746
+ : isOwner
747
+ ? 'owner'
748
+ : isModerator
749
+ ? fallback.moderation_level === 'admin'
750
+ ? 'admin'
751
+ : 'moderator'
752
+ : fallback.moderation_level
753
+ const canManageDerived = isOwner || isModerator || hasAnyPermission([
754
+ 'group.details.edit',
755
+ 'group.settings.manage',
756
+ 'group.visibility.manage',
757
+ 'group.rules.manage',
758
+ 'group.roles.manage',
759
+ 'group.roles.assign',
760
+ 'group.members.invite',
761
+ 'group.members.remove',
762
+ 'group.members.mute',
763
+ 'group.members.unmute',
764
+ 'group.members.ban',
765
+ 'group.members.unban',
766
+ 'group.requests.review',
767
+ 'group.reports.review',
768
+ 'group.audit.view',
769
+ 'group.automod.manage',
770
+ 'group.owner.transfer',
771
+ ])
772
+ const canManageMembersDerived = hasAnyPermission([
773
+ 'group.members.invite',
774
+ 'group.members.remove',
775
+ 'group.members.mute',
776
+ 'group.members.unmute',
777
+ 'group.members.ban',
778
+ 'group.members.unban',
779
+ 'group.roles.assign',
780
+ ])
781
+ const canManageRolesDerived = hasAnyPermission([
782
+ 'group.roles.manage',
783
+ 'group.roles.assign',
784
+ ])
785
+ const canReviewRequestsDerived = hasPermission('group.requests.review')
786
+ const canManageReportsDerived = hasPermission('group.reports.review')
787
+ const canManageBansDerived = hasAnyPermission(['group.members.ban', 'group.members.unban'])
788
+ const canManageMutesDerived = hasAnyPermission(['group.members.mute', 'group.members.unmute'])
789
+ const canViewAuditLogDerived = hasPermission('group.audit.view')
790
+ const canManageAutomodDerived = hasPermission('group.automod.manage')
791
+ const canManageSettingsDerived = hasAnyPermission([
792
+ 'group.details.edit',
793
+ 'group.settings.manage',
794
+ 'group.visibility.manage',
795
+ 'group.rules.manage',
796
+ ])
797
+ const canTransferOwnershipDerived = hasPermission('group.owner.transfer')
798
+
799
+ return {
800
+ is_owner: isOwner || fallback.is_owner,
801
+ is_moderator: isModerator || fallback.is_moderator,
802
+ moderation_level: moderationLevel,
803
+ permissions: permissions.length > 0 ? permissions : fallback.permissions,
804
+ can: Object.keys(permissionMap).length > 0 ? permissionMap : fallback.can,
805
+ canJoin: getFlag(['canJoin', 'can_join'], fallback.canJoin),
806
+ canLeave: getFlag(['canLeave', 'can_leave'], fallback.canLeave),
807
+ canRequestToJoin: getFlag(['canRequestToJoin', 'can_request_to_join'], fallback.canRequestToJoin),
808
+ canInvite: getFlag(['canInvite', 'can_invite'], hasPermission('group.members.invite') || fallback.canInvite),
809
+ canManage: getFlag(['canManage', 'can_manage'], canManageDerived || fallback.canManage),
810
+ canManageMembers: getFlag(['canManageMembers', 'can_manage_members'], canManageMembersDerived || fallback.canManageMembers),
811
+ canManageRoles: getFlag(['canManageRoles', 'can_manage_roles'], canManageRolesDerived || fallback.canManageRoles),
812
+ canReviewRequests: getFlag(['canReviewRequests', 'can_review_requests'], canReviewRequestsDerived || fallback.canReviewRequests),
813
+ canManageReports: getFlag(['canManageReports', 'can_manage_reports'], canManageReportsDerived || fallback.canManageReports),
814
+ canManageBans: getFlag(['canManageBans', 'can_manage_bans'], canManageBansDerived || fallback.canManageBans),
815
+ canManageMutes: getFlag(['canManageMutes', 'can_manage_mutes'], canManageMutesDerived || fallback.canManageMutes),
816
+ canViewAuditLog: getFlag(['canViewAuditLog', 'can_view_audit_log'], canViewAuditLogDerived || fallback.canViewAuditLog),
817
+ canManageAutomod: getFlag(['canManageAutomod', 'can_manage_automod'], canManageAutomodDerived || fallback.canManageAutomod),
818
+ canManageSettings: getFlag(['canManageSettings', 'can_manage_settings'], canManageSettingsDerived || fallback.canManageSettings),
819
+ canTransferOwnership: getFlag(['canTransferOwnership', 'can_transfer_ownership'], canTransferOwnershipDerived || fallback.canTransferOwnership),
820
+ canViewManagement: getFlag(['canViewManagement', 'can_view_management'], canManageDerived || fallback.canViewManagement),
821
+ }
822
+ }
823
+
824
+ function buildActorContext(circle: Circle): CircleActorContext {
825
+ const role = isCircleRole(circle.user_role) ? circle.user_role : null
826
+ const isMember = circle.is_member === true || role !== null
827
+ const defaultCapabilities = buildDefaultActorCapabilities(role, circle.visibility, isMember)
828
+ const capabilities = normalizeActorCapabilities(circle.actor_capabilities ?? null, {
829
+ ...defaultCapabilities,
830
+ canJoin: circle.can_join ?? defaultCapabilities.canJoin,
831
+ canLeave: circle.can_leave ?? defaultCapabilities.canLeave,
832
+ canRequestToJoin: circle.can_request_to_join ?? defaultCapabilities.canRequestToJoin,
833
+ canInvite: circle.can_invite ?? defaultCapabilities.canInvite,
834
+ canManage: circle.can_manage ?? defaultCapabilities.canManage,
835
+ canManageMembers: circle.can_manage_members ?? defaultCapabilities.canManageMembers,
836
+ })
837
+
838
+ const defaultSections = capabilities.canViewManagement
839
+ ? [...CIRCLE_MANAGEMENT_SECTIONS]
840
+ : []
841
+ const managementSections = circle.management_sections && circle.management_sections.length > 0
842
+ ? [...circle.management_sections]
843
+ : defaultSections
844
+
845
+ return {
846
+ isMember,
847
+ role,
848
+ hasPendingRequest: circle.has_pending_request === true,
849
+ capabilities,
850
+ managementSections,
851
+ }
852
+ }
853
+
854
+ function normalizeCollectionResponse<T>(
855
+ payload: unknown,
856
+ normalizeItem: (value: unknown) => T | null,
857
+ ): PaginationResponse<T> {
858
+ const data = readRecord(payload, 'data') ?? (isRecord(payload) ? payload : null)
859
+ const dataItems = readArray(payload, 'data')
860
+ const meta = readRecord(payload, 'meta')
861
+ const dataMeta = readRecord(data, 'meta')
862
+ const pagination = readRecord(meta, 'pagination') ?? readRecord(dataMeta, 'pagination')
863
+ const itemsValue = Array.isArray(data?.items)
864
+ ? data.items
865
+ : dataItems ?? (Array.isArray(payload) ? payload : null)
866
+ const items = Array.isArray(itemsValue)
867
+ ? itemsValue.map((item) => normalizeItem(item)).filter((item): item is T => item !== null)
868
+ : []
869
+
870
+ const nextCursor = readNullableString(pagination?.next_cursor)
871
+ ?? readNullableString(data?.next)
872
+ const hasMore = readBoolean(pagination?.has_more) ?? (nextCursor !== null)
873
+
874
+ return {
875
+ data: items,
876
+ meta: {
877
+ pagination: {
878
+ next_cursor: nextCursor,
879
+ has_more: hasMore,
880
+ },
881
+ },
882
+ }
883
+ }
884
+
885
+ function normalizeInlineCollection<T>(
886
+ items: unknown,
887
+ normalizeItem: (value: unknown) => T | null,
888
+ ): PaginationResponse<T> {
889
+ if (!Array.isArray(items)) {
890
+ return createEmptyPagination<T>()
891
+ }
892
+
893
+ return {
894
+ data: items.map((item) => normalizeItem(item)).filter((item): item is T => item !== null),
895
+ meta: createEmptyPagination<T>().meta,
896
+ }
897
+ }
898
+
899
+ function normalizeCircle(value: unknown): Circle | null {
900
+ if (!isRecord(value)) {
901
+ return null
902
+ }
903
+
904
+ const id = readString(value.id)
905
+ const name = readString(value.name)
906
+ const slug = readString(value.slug)
907
+ const visibility = value.visibility
908
+
909
+ if (id === null || name === null || slug === null || !isCircleVisibility(visibility)) {
910
+ return null
911
+ }
912
+
913
+ const managementSections = normalizeManagementSections(value.management_sections)
914
+ const circle: Circle = {
915
+ id,
916
+ name,
917
+ slug,
918
+ description: readNullableString(value.description),
919
+ visibility,
920
+ is_hidden: readBoolean(value.is_hidden) ?? false,
921
+ owner_id: readNullableString(value.owner_id),
922
+ meta: isRecord(value.meta) ? value.meta : {},
923
+ cover_photo_url: readNullableString(value.cover_photo_url),
924
+ cover_photo_media_id: readNullableString(value.cover_photo_media_id),
925
+ created_at: readString(value.created_at) ?? new Date(0).toISOString(),
926
+ updated_at: readString(value.updated_at) ?? new Date(0).toISOString(),
927
+ user_role: isCircleRole(value.user_role) ? value.user_role : null,
928
+ group_rules_summary: readNullableString(value.group_rules_summary),
929
+ welcome_message: readNullableString(value.welcome_message),
930
+ canonical_path: readNullableString(value.canonical_path),
931
+ }
932
+
933
+ const isMember = readBoolean(value.is_member)
934
+ if (isMember !== null) {
935
+ circle.is_member = isMember
936
+ }
937
+
938
+ const memberCount = readNumber(value.member_count)
939
+ if (memberCount !== null) {
940
+ circle.member_count = memberCount
941
+ }
942
+
943
+ const canJoin = readBoolean(value.can_join)
944
+ if (canJoin !== null) {
945
+ circle.can_join = canJoin
946
+ }
947
+
948
+ const canManage = readBoolean(value.can_manage)
949
+ if (canManage !== null) {
950
+ circle.can_manage = canManage
951
+ }
952
+
953
+ const canManageMembers = readBoolean(value.can_manage_members)
954
+ if (canManageMembers !== null) {
955
+ circle.can_manage_members = canManageMembers
956
+ }
957
+
958
+ const canInvite = readBoolean(value.can_invite)
959
+ if (canInvite !== null) {
960
+ circle.can_invite = canInvite
961
+ }
962
+
963
+ const canLeave = readBoolean(value.can_leave)
964
+ if (canLeave !== null) {
965
+ circle.can_leave = canLeave
966
+ }
967
+
968
+ const canRequestToJoin = readBoolean(value.can_request_to_join)
969
+ if (canRequestToJoin !== null) {
970
+ circle.can_request_to_join = canRequestToJoin
971
+ }
972
+
973
+ const hasPendingRequest = readBoolean(value.has_pending_request)
974
+ if (hasPendingRequest !== null) {
975
+ circle.has_pending_request = hasPendingRequest
976
+ }
977
+
978
+ if (isRecord(value.actor_capabilities)) {
979
+ circle.actor_capabilities = normalizeActorCapabilities(
980
+ value.actor_capabilities,
981
+ buildDefaultActorCapabilities(null, visibility, false),
982
+ )
983
+ }
984
+
985
+ if (managementSections.length > 0) {
986
+ circle.management_sections = managementSections
987
+ }
988
+
989
+ const creator = normalizeUserSummary(value.creator)
990
+ if (creator) {
991
+ circle.creator = creator
992
+ }
993
+
994
+ circle.actor = buildActorContext(circle)
995
+ circle.actor_capabilities = circle.actor.capabilities
996
+ circle.management_sections = circle.actor.managementSections
997
+ circle.can_join = circle.actor.capabilities.canJoin
998
+ circle.can_leave = circle.actor.capabilities.canLeave
999
+ circle.can_request_to_join = circle.actor.capabilities.canRequestToJoin
1000
+ circle.can_invite = circle.actor.capabilities.canInvite
1001
+ circle.can_manage = circle.actor.capabilities.canManage
1002
+ circle.can_manage_members = circle.actor.capabilities.canManageMembers
1003
+ circle.has_pending_request = circle.actor.hasPendingRequest
1004
+ circle.is_member = circle.actor.isMember
1005
+ circle.user_role = circle.actor.role
1006
+
1007
+ return circle
1008
+ }
1009
+
1010
+ function normalizeCircleMember(value: unknown): CircleMember | null {
1011
+ if (!isRecord(value)) {
1012
+ return null
1013
+ }
1014
+
1015
+ const id = readString(value.id)
1016
+ const userId = readString(value.user_id) ?? id
1017
+ const circleId = readString(value.circle_id) ?? ''
1018
+ const joinedAt = readString(value.joined_at)
1019
+ const role = value.role
1020
+
1021
+ if (id === null || userId === null || joinedAt === null || !isCircleRole(role)) {
1022
+ return null
1023
+ }
1024
+
1025
+ const user = normalizeUserSummary(value.user)
1026
+ const profileOverridesValue = isRecord(value.profile_overrides) ? value.profile_overrides : null
1027
+ let profileOverrides: CircleProfileOverrides | undefined
1028
+ if (profileOverridesValue !== null) {
1029
+ const nextProfileOverrides: CircleProfileOverrides = {}
1030
+ const overrideName = readString(profileOverridesValue.name)
1031
+ if (overrideName !== null) {
1032
+ nextProfileOverrides.name = overrideName
1033
+ }
1034
+
1035
+ const overrideBio = readString(profileOverridesValue.bio)
1036
+ if (overrideBio !== null) {
1037
+ nextProfileOverrides.bio = overrideBio
1038
+ }
1039
+
1040
+ const overrideAvatarUrl = readString(profileOverridesValue.avatar_url)
1041
+ if (overrideAvatarUrl !== null) {
1042
+ nextProfileOverrides.avatar_url = overrideAvatarUrl
1043
+ }
1044
+
1045
+ if (Object.keys(nextProfileOverrides).length > 0) {
1046
+ profileOverrides = nextProfileOverrides
1047
+ }
1048
+ }
1049
+
1050
+ const member: CircleMember = {
1051
+ id,
1052
+ user_id: userId,
1053
+ circle_id: circleId,
1054
+ role,
1055
+ joined_at: joinedAt,
1056
+ }
1057
+
1058
+ if (isCircleMembershipRole(value.membership_role)) {
1059
+ member.membership_role = value.membership_role
1060
+ }
1061
+
1062
+ if (isCircleRole(value.display_role)) {
1063
+ member.display_role = value.display_role
1064
+ }
1065
+
1066
+ const assignedRoles = Array.isArray(value.assigned_roles)
1067
+ ? value.assigned_roles
1068
+ .map((roleDefinition) => normalizeRoleDefinition(roleDefinition))
1069
+ .filter((roleDefinition): roleDefinition is CircleRoleDefinition => roleDefinition !== null)
1070
+ : []
1071
+ if (assignedRoles.length > 0) {
1072
+ member.assigned_roles = assignedRoles
1073
+ }
1074
+
1075
+ const effectivePermissions = normalizePermissionKeys(value.effective_permissions)
1076
+ if (effectivePermissions.length > 0) {
1077
+ member.effective_permissions = effectivePermissions
1078
+ }
1079
+
1080
+ const isMuted = readBoolean(value.is_muted)
1081
+ if (isMuted !== null) {
1082
+ member.is_muted = isMuted
1083
+ }
1084
+
1085
+ const mutedUntil = readNullableString(value.muted_until)
1086
+ if (mutedUntil !== null || value.muted_until === null) {
1087
+ member.muted_until = mutedUntil
1088
+ }
1089
+
1090
+ const isBanned = readBoolean(value.is_banned)
1091
+ if (isBanned !== null) {
1092
+ member.is_banned = isBanned
1093
+ }
1094
+
1095
+ const canRemove = readBoolean(value.can_remove)
1096
+ if (canRemove !== null) {
1097
+ member.can_remove = canRemove
1098
+ }
1099
+
1100
+ const canChangeRole = readBoolean(value.can_change_role)
1101
+ if (canChangeRole !== null) {
1102
+ member.can_change_role = canChangeRole
1103
+ }
1104
+
1105
+ const canAssignRoles = readBoolean(value.can_assign_roles)
1106
+ if (canAssignRoles !== null) {
1107
+ member.can_assign_roles = canAssignRoles
1108
+ }
1109
+
1110
+ const canMute = readBoolean(value.can_mute)
1111
+ if (canMute !== null) {
1112
+ member.can_mute = canMute
1113
+ }
1114
+
1115
+ const canUnmute = readBoolean(value.can_unmute)
1116
+ if (canUnmute !== null) {
1117
+ member.can_unmute = canUnmute
1118
+ }
1119
+
1120
+ const canBan = readBoolean(value.can_ban)
1121
+ if (canBan !== null) {
1122
+ member.can_ban = canBan
1123
+ }
1124
+
1125
+ const canUnban = readBoolean(value.can_unban)
1126
+ if (canUnban !== null) {
1127
+ member.can_unban = canUnban
1128
+ }
1129
+
1130
+ const canViewAudit = readBoolean(value.can_view_audit)
1131
+ if (canViewAudit !== null) {
1132
+ member.can_view_audit = canViewAudit
1133
+ }
1134
+
1135
+ if (profileOverrides) {
1136
+ member.profile_overrides = profileOverrides
1137
+ }
1138
+
1139
+ if (user) {
1140
+ member.user = user
1141
+ }
1142
+
1143
+ return member
1144
+ }
1145
+
1146
+ function normalizeJoinRequest(value: unknown): JoinRequest | null {
1147
+ if (!isRecord(value)) {
1148
+ return null
1149
+ }
1150
+
1151
+ const id = readString(value.id)
1152
+ const userId = readString(value.user_id)
1153
+ const circleId = readString(value.circle_id)
1154
+ const status = value.status
1155
+ const source = value.source
1156
+ const createdAt = readString(value.created_at)
1157
+ const updatedAt = readString(value.updated_at)
1158
+
1159
+ if (
1160
+ id === null
1161
+ || userId === null
1162
+ || circleId === null
1163
+ || createdAt === null
1164
+ || updatedAt === null
1165
+ || (status !== 'pending' && status !== 'approved' && status !== 'rejected')
1166
+ || (source !== 'direct' && source !== 'invite')
1167
+ ) {
1168
+ return null
1169
+ }
1170
+
1171
+ const joinRequest: JoinRequest = {
1172
+ id,
1173
+ user_id: userId,
1174
+ circle_id: circleId,
1175
+ status,
1176
+ source,
1177
+ message: readNullableString(value.message),
1178
+ reviewed_by_id: readNullableString(value.reviewed_by_id),
1179
+ reviewed_at: readNullableString(value.reviewed_at),
1180
+ created_at: createdAt,
1181
+ updated_at: updatedAt,
1182
+ }
1183
+
1184
+ const user = normalizeUserSummary(value.user)
1185
+ if (user) {
1186
+ joinRequest.user = user
1187
+ }
1188
+
1189
+ return joinRequest
1190
+ }
1191
+
1192
+ function normalizeInviteLink(value: unknown): InviteLink | null {
1193
+ if (!isRecord(value)) {
1194
+ return null
1195
+ }
1196
+
1197
+ const id = readString(value.id)
1198
+ const token = readString(value.token)
1199
+ const circleId = readString(value.circle_id)
1200
+ const inviteUrl = readString(value.invite_url)
1201
+ const qrSvg = readString(value.qr_svg)
1202
+ const usesCount = readNumber(value.uses_count)
1203
+
1204
+ if (id === null || token === null || circleId === null || inviteUrl === null || qrSvg === null || usesCount === null) {
1205
+ return null
1206
+ }
1207
+
1208
+ return {
1209
+ id,
1210
+ token,
1211
+ circle_id: circleId,
1212
+ target_user_id: readNullableString(value.target_user_id),
1213
+ max_uses: readNumber(value.max_uses),
1214
+ uses_count: usesCount,
1215
+ expires_at: readNullableString(value.expires_at),
1216
+ invite_url: inviteUrl,
1217
+ qr_svg: qrSvg,
1218
+ }
1219
+ }
1220
+
1221
+ function normalizeRoleDefinition(value: unknown): CircleRoleDefinition | null {
1222
+ if (!isRecord(value)) {
1223
+ return null
1224
+ }
1225
+
1226
+ const id = readString(value.id)
1227
+ const slug = readString(value.slug)
1228
+ const name = readString(value.name)
1229
+
1230
+ if (id === null || slug === null || name === null) {
1231
+ return null
1232
+ }
1233
+
1234
+ return {
1235
+ id,
1236
+ slug,
1237
+ name,
1238
+ description: readNullableString(value.description),
1239
+ color: readNullableString(value.color),
1240
+ is_system: readBoolean(value.is_system) ?? false,
1241
+ is_assignable: readBoolean(value.is_assignable) ?? true,
1242
+ member_count: readNumber(value.member_count) ?? 0,
1243
+ permissions: normalizePermissionKeys(value.permissions),
1244
+ }
1245
+ }
1246
+
1247
+ function normalizePermissionCatalogEntry(value: unknown): CirclePermissionCatalogEntry | null {
1248
+ if (!isRecord(value)) {
1249
+ return null
1250
+ }
1251
+
1252
+ const key = readString(value.key)
1253
+ const label = readString(value.label)
1254
+ const description = readString(value.description)
1255
+ const moduleName = readString(value.module)
1256
+ const section = value.section
1257
+
1258
+ if (key === null || label === null || description === null || moduleName === null || !isManagementSection(section)) {
1259
+ return null
1260
+ }
1261
+
1262
+ return {
1263
+ key,
1264
+ label,
1265
+ description,
1266
+ module: moduleName,
1267
+ owner_only: readBoolean(value.owner_only) ?? false,
1268
+ surfaced: readBoolean(value.surfaced) ?? false,
1269
+ section,
1270
+ }
1271
+ }
1272
+
1273
+ function normalizeModerationReport(value: unknown): CircleModerationReport | null {
1274
+ if (!isRecord(value)) {
1275
+ return null
1276
+ }
1277
+
1278
+ const id = readString(value.id) ?? readString(value.report_id)
1279
+ const reporterId = readNullableString(value.reporter_id)
1280
+ const source = isCircleModerationReportSource(value.source)
1281
+ ? value.source
1282
+ : 'member'
1283
+ const subjectUserId = readNullableString(value.subject_user_id)
1284
+ const subjectType = isCircleModerationReportSubjectType(value.subject_type)
1285
+ ? value.subject_type
1286
+ : subjectUserId !== null
1287
+ ? 'user'
1288
+ : null
1289
+ const subjectId = readNullableString(value.subject_id) ?? subjectUserId
1290
+ const subjectAuthorId = readNullableString(value.subject_author_id)
1291
+ ?? (subjectType === 'user' ? subjectUserId : null)
1292
+ const category = readString(value.category) ?? 'general'
1293
+ const status = value.status
1294
+ const priority = readString(value.priority) ?? 'normal'
1295
+ const createdAt = readString(value.created_at) ?? new Date(0).toISOString()
1296
+
1297
+ if (
1298
+ id === null
1299
+ || (status !== 'pending' && status !== 'actioned' && status !== 'dismissed')
1300
+ ) {
1301
+ return null
1302
+ }
1303
+
1304
+ return {
1305
+ id,
1306
+ reporter_id: reporterId,
1307
+ source,
1308
+ subject_user_id: subjectUserId,
1309
+ subject_type: subjectType,
1310
+ subject_id: subjectId,
1311
+ subject_author_id: subjectAuthorId,
1312
+ category,
1313
+ notes: readNullableString(value.notes),
1314
+ status,
1315
+ priority,
1316
+ resolution_action: readNullableString(value.resolution_action),
1317
+ created_at: createdAt,
1318
+ reviewed_at: readNullableString(value.reviewed_at),
1319
+ resolution_notes: readNullableString(value.resolution_notes),
1320
+ }
1321
+ }
1322
+
1323
+ function normalizeBanRecord(value: unknown): CircleBanRecord | null {
1324
+ if (!isRecord(value)) {
1325
+ return null
1326
+ }
1327
+
1328
+ const id = readString(value.id) ?? readString(value.user_id)
1329
+ const userId = readString(value.user_id)
1330
+ const createdAt = readString(value.created_at) ?? new Date(0).toISOString()
1331
+
1332
+ if (id === null || userId === null) {
1333
+ return null
1334
+ }
1335
+
1336
+ const banRecord: CircleBanRecord = {
1337
+ id,
1338
+ user_id: userId,
1339
+ reason: readNullableString(value.reason),
1340
+ created_at: createdAt,
1341
+ updated_at: readNullableString(value.updated_at),
1342
+ created_by_id: readNullableString(value.created_by_id),
1343
+ }
1344
+
1345
+ const user = normalizeUserSummary(value.user)
1346
+ if (user) {
1347
+ banRecord.user = user
1348
+ }
1349
+
1350
+ return banRecord
1351
+ }
1352
+
1353
+ function normalizeMuteRecord(value: unknown): CircleMuteRecord | null {
1354
+ if (!isRecord(value)) {
1355
+ return null
1356
+ }
1357
+
1358
+ const id = readString(value.id) ?? readString(value.user_id)
1359
+ const userId = readString(value.user_id)
1360
+ const createdAt = readString(value.created_at) ?? new Date(0).toISOString()
1361
+
1362
+ if (id === null || userId === null) {
1363
+ return null
1364
+ }
1365
+
1366
+ const muteRecord: CircleMuteRecord = {
1367
+ id,
1368
+ user_id: userId,
1369
+ reason: readNullableString(value.reason),
1370
+ muted_until: readNullableString(value.muted_until),
1371
+ created_at: createdAt,
1372
+ updated_at: readNullableString(value.updated_at),
1373
+ created_by_id: readNullableString(value.created_by_id),
1374
+ }
1375
+
1376
+ const user = normalizeUserSummary(value.user)
1377
+ if (user) {
1378
+ muteRecord.user = user
1379
+ }
1380
+
1381
+ return muteRecord
1382
+ }
1383
+
1384
+ function normalizeAuditLogEntry(value: unknown): CircleModerationAuditLogEntry | null {
1385
+ if (!isRecord(value)) {
1386
+ return null
1387
+ }
1388
+
1389
+ const id = readString(value.id)
1390
+ const action = readString(value.action)
1391
+ const actorId = readString(value.actor_id)
1392
+ const createdAt = readString(value.created_at) ?? new Date(0).toISOString()
1393
+
1394
+ if (id === null || action === null || actorId === null) {
1395
+ return null
1396
+ }
1397
+
1398
+ return {
1399
+ id,
1400
+ action,
1401
+ actor_id: actorId,
1402
+ target_user_id: readNullableString(value.target_user_id),
1403
+ report_id: readNullableString(value.report_id),
1404
+ meta: isRecord(value.meta) ? value.meta : {},
1405
+ created_at: createdAt,
1406
+ }
1407
+ }
1408
+
1409
+ function isCircleModerationSubjectType(value: unknown): value is CircleModerationSubjectType {
1410
+ return value === 'post' || value === 'comment' || value === 'thread' || value === 'reply' || value === 'event'
1411
+ }
1412
+
1413
+ function isCircleModerationSubjectState(value: unknown): value is CircleModerationSubjectState {
1414
+ return value === 'active' || value === 'removed' || value === 'locked'
1415
+ }
1416
+
1417
+ function isCircleModerationSubjectAction(value: unknown): value is CircleModerationSubjectAction {
1418
+ return value === 'remove' || value === 'restore' || value === 'lock' || value === 'unlock'
1419
+ }
1420
+
1421
+ function normalizeModerationSubject(value: unknown): CircleModerationSubject | null {
1422
+ if (!isRecord(value)) {
1423
+ return null
1424
+ }
1425
+
1426
+ const subjectType = value.subject_type
1427
+ const subjectId = readString(value.subject_id)
1428
+ const circleId = readString(value.circle_id)
1429
+ const moderationState = value.moderation_state
1430
+ const createdAt = readString(value.created_at)
1431
+ const updatedAt = readString(value.updated_at)
1432
+
1433
+ if (
1434
+ subjectId === null
1435
+ || circleId === null
1436
+ || createdAt === null
1437
+ || updatedAt === null
1438
+ || !isCircleModerationSubjectType(subjectType)
1439
+ || !isCircleModerationSubjectState(moderationState)
1440
+ ) {
1441
+ return null
1442
+ }
1443
+
1444
+ const actions = Array.isArray(value.available_actions)
1445
+ ? value.available_actions
1446
+ .filter((entry): entry is CircleModerationSubjectAction => isCircleModerationSubjectAction(entry))
1447
+ : []
1448
+
1449
+ const latestReportStatusValue = value.latest_report_status
1450
+ const latestReportStatus: CircleReportStatus | null = latestReportStatusValue === 'pending'
1451
+ || latestReportStatusValue === 'actioned'
1452
+ || latestReportStatusValue === 'dismissed'
1453
+ ? latestReportStatusValue
1454
+ : null
1455
+
1456
+ return {
1457
+ subject_type: subjectType,
1458
+ subject_id: subjectId,
1459
+ circle_id: circleId,
1460
+ author_id: readNullableString(value.author_id),
1461
+ report_count: readNumber(value.report_count) ?? 0,
1462
+ latest_report_status: latestReportStatus,
1463
+ moderation_state: moderationState,
1464
+ created_at: createdAt,
1465
+ updated_at: updatedAt,
1466
+ preview: isRecord(value.preview) ? value.preview : {},
1467
+ available_actions: actions,
1468
+ }
1469
+ }
1470
+
1471
+ function normalizeAutomodCondition(value: unknown): CircleAutomodCondition | null {
1472
+ if (!isRecord(value)) {
1473
+ return null
1474
+ }
1475
+
1476
+ const type = readString(value.type)
1477
+ if (type === null) {
1478
+ return null
1479
+ }
1480
+
1481
+ return {
1482
+ type,
1483
+ config: isRecord(value.config) ? value.config : {},
1484
+ }
1485
+ }
1486
+
1487
+ function normalizeAutomodAction(value: unknown): CircleAutomodAction | null {
1488
+ if (!isRecord(value)) {
1489
+ return null
1490
+ }
1491
+
1492
+ const type = readString(value.type)
1493
+ if (type === null) {
1494
+ return null
1495
+ }
1496
+
1497
+ return {
1498
+ type,
1499
+ config: isRecord(value.config) ? value.config : {},
1500
+ }
1501
+ }
1502
+
1503
+ function normalizeAutomodRule(value: unknown): CircleAutomodRule | null {
1504
+ if (!isRecord(value)) {
1505
+ return null
1506
+ }
1507
+
1508
+ const id = readString(value.id)
1509
+ const name = readString(value.name)
1510
+ const createdAt = readString(value.created_at) ?? new Date(0).toISOString()
1511
+
1512
+ if (id === null || name === null) {
1513
+ return null
1514
+ }
1515
+
1516
+ const conditions = Array.isArray(value.conditions)
1517
+ ? value.conditions.map((condition) => normalizeAutomodCondition(condition)).filter((condition): condition is CircleAutomodCondition => condition !== null)
1518
+ : []
1519
+ const actions = Array.isArray(value.actions)
1520
+ ? value.actions.map((action) => normalizeAutomodAction(action)).filter((action): action is CircleAutomodAction => action !== null)
1521
+ : []
1522
+
1523
+ return {
1524
+ id,
1525
+ name,
1526
+ description: readNullableString(value.description),
1527
+ is_enabled: readBoolean(value.is_enabled) ?? true,
1528
+ conditions,
1529
+ actions,
1530
+ priority: readNumber(value.priority) ?? 0,
1531
+ created_at: createdAt,
1532
+ }
1533
+ }
1534
+
1535
+ function isCircleWithSlug(value: unknown): value is { id: string; slug: string } {
1536
+ if (!isRecord(value)) {
1537
+ return false
1538
+ }
1539
+
1540
+ return typeof value.id === 'string' && typeof value.slug === 'string'
1541
+ }
1542
+
1543
+ function defaultSanitizeString(value: string): string {
1544
+ return value.trim()
1545
+ }
1546
+
1547
+ function defaultGetCircleTerm(options?: CircleTermOptions): string {
1548
+ return options?.case === 'title' ? 'Circle' : 'circle'
1549
+ }
1550
+
1551
+ class CirclesService {
1552
+ private readonly baseURL = '/v1/circles'
1553
+
1554
+ public constructor(
1555
+ private readonly client: AxiosInstance,
1556
+ private readonly sanitizeString: (value: string) => string,
1557
+ private readonly onNotify?: (notification: CirclesNotification) => void,
1558
+ private readonly onUnauthorized?: () => void,
1559
+ private readonly getCircleTerm: (options?: CircleTermOptions) => string = defaultGetCircleTerm,
1560
+ private readonly onError?: (context: CirclesServiceErrorContext) => void,
1561
+ ) {}
1562
+
1563
+ private isUuid(value: string): boolean {
1564
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
1565
+ }
1566
+
1567
+ private sanitizeInput<T>(data: T): SanitizedInput<T> {
1568
+ if (typeof data === 'string') {
1569
+ return this.sanitizeString(data.trim()) as SanitizedInput<T>
1570
+ }
1571
+
1572
+ if (Array.isArray(data)) {
1573
+ return data.map((item) => this.sanitizeInput(item)) as SanitizedInput<T>
1574
+ }
1575
+
1576
+ if (isRecord(data)) {
1577
+ const sanitized: Record<string, unknown> = {}
1578
+ Object.keys(data).forEach((key) => {
1579
+ sanitized[key] = this.sanitizeInput(data[key])
1580
+ })
1581
+ return sanitized as SanitizedInput<T>
1582
+ }
1583
+
1584
+ return data as SanitizedInput<T>
1585
+ }
1586
+
1587
+ private emitNotification(notification: CirclesNotification): void {
1588
+ this.onNotify?.(notification)
1589
+ }
1590
+
1591
+ private reportError(context: CirclesServiceErrorContext): void {
1592
+ const consoleMethod = context.logLevel === 'warn' ? console.warn : console.error
1593
+ const label = context.logLevel === 'warn' ? 'Circles API Not Found:' : 'Circles API Error:'
1594
+
1595
+ consoleMethod(label, {
1596
+ url: context.url,
1597
+ status: context.status,
1598
+ timestamp: context.timestamp,
1599
+ })
1600
+
1601
+ this.onError?.(context)
1602
+ }
1603
+
1604
+ private async handleError(error: unknown): Promise<never> {
1605
+ const apiError = isApiError(error) ? error : null
1606
+ const status = apiError?.response?.status
1607
+ const context: CirclesServiceErrorContext = {
1608
+ error,
1609
+ url: apiError?.config?.url,
1610
+ status,
1611
+ timestamp: new Date().toISOString(),
1612
+ logLevel: status === 404 ? 'warn' : 'error',
1613
+ }
1614
+
1615
+ this.reportError(context)
1616
+
1617
+ let userMessage = 'Something went wrong. Please try again.'
1618
+
1619
+ switch (status) {
1620
+ case 401:
1621
+ userMessage = 'Please sign in to continue.'
1622
+ this.onUnauthorized?.()
1623
+ break
1624
+ case 403:
1625
+ userMessage = `You do not have permission to access this ${this.getCircleTerm()}.`
1626
+ break
1627
+ case 404:
1628
+ userMessage = `${this.getCircleTerm({ case: 'title' })} not found.`
1629
+ break
1630
+ case 422:
1631
+ userMessage = readString(apiError?.response?.data?.message) ?? 'Please check your input and try again.'
1632
+ break
1633
+ case 429:
1634
+ userMessage = 'Too many requests. Please wait a moment.'
1635
+ break
1636
+ }
1637
+
1638
+ this.emitNotification({
1639
+ description: userMessage,
1640
+ color: 'error',
1641
+ duration: 4000,
1642
+ })
1643
+
1644
+ throw new Error(userMessage)
1645
+ }
1646
+
1647
+ private isHttpStatus(error: unknown, status: number): boolean {
1648
+ return isApiError(error) && error.response?.status === status
1649
+ }
1650
+
1651
+ private async findBySlug(slug: string): Promise<Circle | null> {
1652
+ const response = await this.client.get(this.baseURL, {
1653
+ params: {
1654
+ search: this.sanitizeInput(slug),
1655
+ limit: DEFAULT_LIST_LIMIT,
1656
+ },
1657
+ })
1658
+
1659
+ const items = readRecord(response.data, 'data')?.items
1660
+ if (!Array.isArray(items)) {
1661
+ return null
1662
+ }
1663
+
1664
+ const normalizedSlug = slug.trim().toLowerCase()
1665
+ const matchedCircle = items.find((item: unknown) => isCircleWithSlug(item) && item.slug.toLowerCase() === normalizedSlug)
1666
+
1667
+ return matchedCircle ? normalizeCircle(matchedCircle) : null
1668
+ }
1669
+
1670
+ public async list(cursor?: string | null, filters?: CircleFilters): Promise<PaginationResponse<Circle>> {
1671
+ try {
1672
+ const params: Record<string, string> = {
1673
+ limit: DEFAULT_LIST_LIMIT,
1674
+ }
1675
+
1676
+ if (cursor) {
1677
+ params.cursor = cursor
1678
+ }
1679
+
1680
+ if (filters?.visibility) {
1681
+ params.visibility = filters.visibility
1682
+ }
1683
+
1684
+ if (filters?.membership) {
1685
+ params.membership = filters.membership
1686
+ }
1687
+
1688
+ if (filters?.search) {
1689
+ params.search = this.sanitizeInput(filters.search)
1690
+ }
1691
+
1692
+ const response = await this.client.get(this.baseURL, { params })
1693
+ return normalizeCollectionResponse(response.data, normalizeCircle)
1694
+ } catch (error: unknown) {
1695
+ return await this.handleError(error)
1696
+ }
1697
+ }
1698
+
1699
+ public async get(id: string): Promise<Circle> {
1700
+ const identifier = this.sanitizeInput(id)
1701
+
1702
+ try {
1703
+ const response = await this.client.get(`${this.baseURL}/${identifier}`)
1704
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
1705
+ const normalized = normalizeCircle(data?.circle ?? data)
1706
+
1707
+ if (normalized !== null) {
1708
+ return normalized
1709
+ }
1710
+
1711
+ throw new Error('Invalid circle payload')
1712
+ } catch (error: unknown) {
1713
+ if (this.isHttpStatus(error, 404) && !this.isUuid(identifier)) {
1714
+ try {
1715
+ const matchedCircle = await this.findBySlug(identifier)
1716
+ if (matchedCircle !== null) {
1717
+ return matchedCircle
1718
+ }
1719
+ } catch {
1720
+ // Fall through to canonical error handling.
1721
+ }
1722
+ }
1723
+
1724
+ return await this.handleError(error)
1725
+ }
1726
+ }
1727
+
1728
+ public async create(data: CircleCreateInput): Promise<{ id: string }> {
1729
+ try {
1730
+ const response = await this.client.post(this.baseURL, this.sanitizeInput(data))
1731
+ const payload = readRecord(response.data, 'data') ?? {}
1732
+ const id = readString(payload.id)
1733
+
1734
+ if (id === null) {
1735
+ throw new Error('Invalid circle creation payload')
1736
+ }
1737
+
1738
+ return { id }
1739
+ } catch (error: unknown) {
1740
+ return await this.handleError(error)
1741
+ }
1742
+ }
1743
+
1744
+ public async update(id: string, data: CircleUpdateInput): Promise<Circle> {
1745
+ try {
1746
+ await this.client.patch(`${this.baseURL}/${id}`, this.sanitizeInput(data))
1747
+ return await this.get(id)
1748
+ } catch (error: unknown) {
1749
+ return await this.handleError(error)
1750
+ }
1751
+ }
1752
+
1753
+ public async delete(id: string): Promise<void> {
1754
+ try {
1755
+ await this.client.delete(`${this.baseURL}/${id}`)
1756
+ } catch (error: unknown) {
1757
+ return await this.handleError(error)
1758
+ }
1759
+ }
1760
+
1761
+ public async join(id: string): Promise<void> {
1762
+ try {
1763
+ await this.client.post(`${this.baseURL}/${id}/join`)
1764
+ this.emitNotification({
1765
+ description: `Successfully joined ${this.getCircleTerm()}!`,
1766
+ color: 'success',
1767
+ })
1768
+ } catch (error: unknown) {
1769
+ return await this.handleError(error)
1770
+ }
1771
+ }
1772
+
1773
+ public async leave(id: string): Promise<void> {
1774
+ try {
1775
+ await this.client.post(`${this.baseURL}/${id}/leave`)
1776
+ this.emitNotification({
1777
+ description: `Left ${this.getCircleTerm()} successfully`,
1778
+ color: 'success',
1779
+ })
1780
+ } catch (error: unknown) {
1781
+ return await this.handleError(error)
1782
+ }
1783
+ }
1784
+
1785
+ public async getMembers(id: string, cursor?: string | null): Promise<PaginationResponse<CircleMember>> {
1786
+ try {
1787
+ const params: Record<string, string> = {}
1788
+ if (cursor) {
1789
+ params.cursor = cursor
1790
+ }
1791
+
1792
+ const response = await this.client.get(`${this.baseURL}/${id}/members`, { params })
1793
+ return normalizeCollectionResponse(response.data, normalizeCircleMember)
1794
+ } catch (error: unknown) {
1795
+ return await this.handleError(error)
1796
+ }
1797
+ }
1798
+
1799
+ public async requestToJoin(id: string, message?: string): Promise<{ request_id: string; status: string }> {
1800
+ try {
1801
+ const body: Record<string, string> = {}
1802
+ if (message) {
1803
+ body.message = this.sanitizeInput(message)
1804
+ }
1805
+
1806
+ const response = await this.client.post(`${this.baseURL}/${id}/join-requests`, body)
1807
+ const payload = readRecord(response.data, 'data') ?? {}
1808
+ const requestId = readString(payload.request_id)
1809
+ const status = readString(payload.status)
1810
+
1811
+ if (requestId === null || status === null) {
1812
+ throw new Error('Invalid join request payload')
1813
+ }
1814
+
1815
+ this.emitNotification({
1816
+ description: `Join request sent! The ${this.getCircleTerm()} admins will review it.`,
1817
+ color: 'success',
1818
+ duration: 4000,
1819
+ })
1820
+
1821
+ return {
1822
+ request_id: requestId,
1823
+ status,
1824
+ }
1825
+ } catch (error: unknown) {
1826
+ return await this.handleError(error)
1827
+ }
1828
+ }
1829
+
1830
+ public async listJoinRequests(circleId: string, cursor?: string | null, status?: string): Promise<PaginationResponse<JoinRequest>> {
1831
+ try {
1832
+ const params: Record<string, string> = {}
1833
+ if (cursor) {
1834
+ params.cursor = cursor
1835
+ }
1836
+ if (status) {
1837
+ params.status = status
1838
+ }
1839
+
1840
+ const response = await this.client.get(`${this.baseURL}/${circleId}/join-requests`, { params })
1841
+ return normalizeCollectionResponse(response.data, normalizeJoinRequest)
1842
+ } catch (error: unknown) {
1843
+ return await this.handleError(error)
1844
+ }
1845
+ }
1846
+
1847
+ public async moderateJoinRequest(circleId: string, requestId: string, decision: 'approve' | 'reject'): Promise<{ request_id: string; status: string }> {
1848
+ try {
1849
+ const response = await this.client.patch(`${this.baseURL}/${circleId}/join-requests/${requestId}`, { decision })
1850
+ const payload = readRecord(response.data, 'data') ?? {}
1851
+ const status = readString(payload.status)
1852
+ const resolvedRequestId = readString(payload.request_id) ?? requestId
1853
+
1854
+ if (status === null) {
1855
+ throw new Error('Invalid join request moderation payload')
1856
+ }
1857
+
1858
+ this.emitNotification({
1859
+ description: `Join request ${decision === 'approve' ? 'approved' : 'rejected'}.`,
1860
+ color: decision === 'approve' ? 'success' : 'warning',
1861
+ })
1862
+
1863
+ return {
1864
+ request_id: resolvedRequestId,
1865
+ status,
1866
+ }
1867
+ } catch (error: unknown) {
1868
+ return await this.handleError(error)
1869
+ }
1870
+ }
1871
+
1872
+ public async fetchRoleDefinitions(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleRoleDefinition>> {
1873
+ return await this.listRoleDefinitions(circleId, cursor)
1874
+ }
1875
+
1876
+ public async listRoleDefinitions(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleRoleDefinition>> {
1877
+ try {
1878
+ const params: Record<string, string> = {}
1879
+ if (cursor) {
1880
+ params.cursor = cursor
1881
+ }
1882
+
1883
+ const response = await this.client.get(`${this.baseURL}/${circleId}/roles`, { params })
1884
+ const directCollection = normalizeCollectionResponse(response.data, normalizeRoleDefinition)
1885
+ if (directCollection.data.length > 0 || directCollection.meta.pagination.has_more) {
1886
+ return directCollection
1887
+ }
1888
+
1889
+ const data = readRecord(response.data, 'data')
1890
+ const roles = readArray(response.data, 'data') ?? data?.roles ?? data?.items
1891
+ if (Array.isArray(roles)) {
1892
+ return normalizeInlineCollection(roles, normalizeRoleDefinition)
1893
+ }
1894
+
1895
+ return createEmptyPagination<CircleRoleDefinition>()
1896
+ } catch (error: unknown) {
1897
+ if (this.isHttpStatus(error, 404)) {
1898
+ return createEmptyPagination<CircleRoleDefinition>()
1899
+ }
1900
+
1901
+ return await this.handleError(error)
1902
+ }
1903
+ }
1904
+
1905
+ public async listRoles(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleRoleDefinition>> {
1906
+ return await this.listRoleDefinitions(circleId, cursor)
1907
+ }
1908
+
1909
+ public async createRoleDefinition(circleId: string, input: CircleRoleDefinitionInput): Promise<CircleRoleDefinition> {
1910
+ try {
1911
+ const response = await this.client.post(`${this.baseURL}/${circleId}/roles`, this.sanitizeInput(input))
1912
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
1913
+ const normalized = normalizeRoleDefinition(data)
1914
+
1915
+ if (normalized !== null) {
1916
+ this.emitNotification({
1917
+ description: 'Group role created.',
1918
+ color: 'success',
1919
+ })
1920
+ return normalized
1921
+ }
1922
+
1923
+ throw new Error('Invalid group role payload')
1924
+ } catch (error: unknown) {
1925
+ return await this.handleError(error)
1926
+ }
1927
+ }
1928
+
1929
+ public async createRole(circleId: string, input: CircleRoleDefinitionInput): Promise<CircleRoleDefinition> {
1930
+ return await this.createRoleDefinition(circleId, input)
1931
+ }
1932
+
1933
+ public async updateRoleDefinition(circleId: string, roleId: string, input: CircleRoleDefinitionUpdateInput): Promise<CircleRoleDefinition> {
1934
+ try {
1935
+ const response = await this.client.patch(`${this.baseURL}/${circleId}/roles/${roleId}`, this.sanitizeInput(input))
1936
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
1937
+ const normalized = normalizeRoleDefinition(data)
1938
+
1939
+ if (normalized !== null) {
1940
+ this.emitNotification({
1941
+ description: 'Group role updated.',
1942
+ color: 'success',
1943
+ })
1944
+ return normalized
1945
+ }
1946
+
1947
+ throw new Error('Invalid group role payload')
1948
+ } catch (error: unknown) {
1949
+ return await this.handleError(error)
1950
+ }
1951
+ }
1952
+
1953
+ public async updateRole(circleId: string, roleId: string, input: CircleRoleDefinitionUpdateInput): Promise<CircleRoleDefinition> {
1954
+ return await this.updateRoleDefinition(circleId, roleId, input)
1955
+ }
1956
+
1957
+ public async replaceRoleDefinitionPermissions(
1958
+ circleId: string,
1959
+ roleId: string,
1960
+ permissions: CirclePermissionKey[],
1961
+ ): Promise<CircleRoleDefinition> {
1962
+ try {
1963
+ const response = await this.client.put(`${this.baseURL}/${circleId}/roles/${roleId}/permissions`, {
1964
+ permissions: this.sanitizeInput(permissions),
1965
+ })
1966
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
1967
+ const normalized = normalizeRoleDefinition(data)
1968
+
1969
+ if (normalized !== null) {
1970
+ this.emitNotification({
1971
+ description: 'Group role permissions updated.',
1972
+ color: 'success',
1973
+ })
1974
+ return normalized
1975
+ }
1976
+
1977
+ throw new Error('Invalid group role payload')
1978
+ } catch (error: unknown) {
1979
+ return await this.handleError(error)
1980
+ }
1981
+ }
1982
+
1983
+ public async replaceRolePermissions(
1984
+ circleId: string,
1985
+ roleId: string,
1986
+ permissions: CirclePermissionKey[],
1987
+ ): Promise<CircleRoleDefinition> {
1988
+ return await this.replaceRoleDefinitionPermissions(circleId, roleId, permissions)
1989
+ }
1990
+
1991
+ public async archiveRoleDefinition(circleId: string, roleId: string): Promise<void> {
1992
+ try {
1993
+ await this.client.delete(`${this.baseURL}/${circleId}/roles/${roleId}`)
1994
+ this.emitNotification({
1995
+ description: 'Group role archived.',
1996
+ color: 'success',
1997
+ })
1998
+ } catch (error: unknown) {
1999
+ return await this.handleError(error)
2000
+ }
2001
+ }
2002
+
2003
+ public async archiveRole(circleId: string, roleId: string): Promise<void> {
2004
+ return await this.archiveRoleDefinition(circleId, roleId)
2005
+ }
2006
+
2007
+ public async assignMemberRoles(
2008
+ circleId: string,
2009
+ userId: string,
2010
+ roleIds: string[],
2011
+ ): Promise<CircleMemberRoleAssignmentResult> {
2012
+ try {
2013
+ const response = await this.client.post(`${this.baseURL}/${circleId}/members/${userId}/roles`, {
2014
+ role_ids: this.sanitizeInput(roleIds),
2015
+ })
2016
+ const data = readRecord(response.data, 'data')
2017
+
2018
+ return {
2019
+ assigned_roles: Array.isArray(data?.assigned_roles)
2020
+ ? data.assigned_roles
2021
+ .map((roleDefinition) => normalizeRoleDefinition(roleDefinition))
2022
+ .filter((roleDefinition): roleDefinition is CircleRoleDefinition => roleDefinition !== null)
2023
+ : [],
2024
+ effective_permissions: normalizePermissionKeys(data?.effective_permissions),
2025
+ }
2026
+ } catch (error: unknown) {
2027
+ return await this.handleError(error)
2028
+ }
2029
+ }
2030
+
2031
+ public async removeMemberRole(circleId: string, userId: string, roleId: string): Promise<void> {
2032
+ try {
2033
+ await this.client.delete(`${this.baseURL}/${circleId}/members/${userId}/roles/${roleId}`)
2034
+ this.emitNotification({
2035
+ description: 'Member role removed.',
2036
+ color: 'success',
2037
+ })
2038
+ } catch (error: unknown) {
2039
+ return await this.handleError(error)
2040
+ }
2041
+ }
2042
+
2043
+ public async changeMemberRole(circleId: string, userId: string, role: 'admin' | 'member'): Promise<void> {
2044
+ try {
2045
+ try {
2046
+ await this.client.patch(`${this.baseURL}/${circleId}/roles/${userId}`, { role })
2047
+ } catch (error: unknown) {
2048
+ if (!this.isHttpStatus(error, 404) && !this.isHttpStatus(error, 405)) {
2049
+ throw error
2050
+ }
2051
+
2052
+ await this.client.patch(`${this.baseURL}/${circleId}/members/role`, { user_id: userId, role })
2053
+ }
2054
+
2055
+ const roleLabel = role === 'admin' ? 'an admin' : 'a member'
2056
+ this.emitNotification({
2057
+ description: `Member role changed to ${roleLabel}.`,
2058
+ color: 'success',
2059
+ })
2060
+ } catch (error: unknown) {
2061
+ return await this.handleError(error)
2062
+ }
2063
+ }
2064
+
2065
+ public async transferOwnership(
2066
+ circleId: string,
2067
+ userId: string,
2068
+ options?: CircleOwnershipTransferOptions,
2069
+ ): Promise<CircleOwnershipTransferResult> {
2070
+ try {
2071
+ const payload: Record<string, string | boolean> = {
2072
+ user_id: userId,
2073
+ }
2074
+
2075
+ if (typeof options?.note === 'string' && options.note.trim().length > 0) {
2076
+ payload.note = this.sanitizeInput(options.note)
2077
+ }
2078
+
2079
+ if (typeof options?.assign_previous_owner_admin === 'boolean') {
2080
+ payload.assign_previous_owner_admin = options.assign_previous_owner_admin
2081
+ }
2082
+
2083
+ const response = await this.client.post(`${this.baseURL}/${circleId}/owner/transfer`, payload)
2084
+ const data = readRecord(response.data, 'data') ?? {}
2085
+ const resolvedCircleId = readString(data.circle_id)
2086
+ const previousOwnerId = readString(data.previous_owner_id)
2087
+ const newOwnerId = readString(data.new_owner_id)
2088
+
2089
+ if (resolvedCircleId === null || previousOwnerId === null || newOwnerId === null) {
2090
+ throw new Error('Invalid ownership transfer payload')
2091
+ }
2092
+
2093
+ this.emitNotification({
2094
+ description: `${this.getCircleTerm({ case: 'title' })} ownership transferred.`,
2095
+ color: 'success',
2096
+ })
2097
+
2098
+ return {
2099
+ circle_id: resolvedCircleId,
2100
+ previous_owner_id: previousOwnerId,
2101
+ new_owner_id: newOwnerId,
2102
+ }
2103
+ } catch (error: unknown) {
2104
+ return await this.handleError(error)
2105
+ }
2106
+ }
2107
+
2108
+ public async removeMember(circleId: string, userId: string): Promise<void> {
2109
+ try {
2110
+ await this.client.delete(`${this.baseURL}/${circleId}/members`, { data: { user_id: userId } })
2111
+ this.emitNotification({
2112
+ description: `Member removed from ${this.getCircleTerm()}.`,
2113
+ color: 'success',
2114
+ })
2115
+ } catch (error: unknown) {
2116
+ return await this.handleError(error)
2117
+ }
2118
+ }
2119
+
2120
+ public async banMember(circleId: string, userId: string, options?: CircleBanMemberOptions): Promise<void> {
2121
+ try {
2122
+ const payload: Record<string, string> = { user_id: userId }
2123
+ if (typeof options?.reason === 'string' && options.reason.trim().length > 0) {
2124
+ payload.reason = this.sanitizeInput(options.reason)
2125
+ }
2126
+
2127
+ await this.client.post(`${this.baseURL}/${circleId}/moderation/bans`, payload)
2128
+ this.emitNotification({
2129
+ description: `Member banned from ${this.getCircleTerm()}.`,
2130
+ color: 'success',
2131
+ })
2132
+ } catch (error: unknown) {
2133
+ return await this.handleError(error)
2134
+ }
2135
+ }
2136
+
2137
+ public async listBans(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleBanRecord>> {
2138
+ try {
2139
+ const params: Record<string, string> = {}
2140
+ if (cursor) {
2141
+ params.cursor = cursor
2142
+ }
2143
+
2144
+ const response = await this.client.get(`${this.baseURL}/${circleId}/moderation/bans`, { params })
2145
+ return normalizeCollectionResponse(response.data, normalizeBanRecord)
2146
+ } catch (error: unknown) {
2147
+ if (this.isHttpStatus(error, 404)) {
2148
+ return createEmptyPagination<CircleBanRecord>()
2149
+ }
2150
+
2151
+ return await this.handleError(error)
2152
+ }
2153
+ }
2154
+
2155
+ public async unbanMember(circleId: string, userId: string): Promise<void> {
2156
+ try {
2157
+ await this.client.delete(`${this.baseURL}/${circleId}/moderation/bans/${userId}`)
2158
+ this.emitNotification({
2159
+ description: `Member unbanned in ${this.getCircleTerm()}.`,
2160
+ color: 'success',
2161
+ })
2162
+ } catch (error: unknown) {
2163
+ return await this.handleError(error)
2164
+ }
2165
+ }
2166
+
2167
+ public async muteMember(
2168
+ circleId: string,
2169
+ userId: string,
2170
+ options?: CircleMuteMemberOptions,
2171
+ ): Promise<void> {
2172
+ try {
2173
+ const payload: Record<string, string> = { user_id: userId }
2174
+ if (typeof options?.reason === 'string' && options.reason.trim().length > 0) {
2175
+ payload.reason = this.sanitizeInput(options.reason)
2176
+ }
2177
+ if (typeof options?.muted_until === 'string' && options.muted_until.trim().length > 0) {
2178
+ payload.muted_until = options.muted_until.trim()
2179
+ }
2180
+
2181
+ await this.client.post(`${this.baseURL}/${circleId}/moderation/mutes`, payload)
2182
+ this.emitNotification({
2183
+ description: `Member muted in ${this.getCircleTerm()}.`,
2184
+ color: 'success',
2185
+ })
2186
+ } catch (error: unknown) {
2187
+ return await this.handleError(error)
2188
+ }
2189
+ }
2190
+
2191
+ public async listMutes(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleMuteRecord>> {
2192
+ try {
2193
+ const params: Record<string, string> = {}
2194
+ if (cursor) {
2195
+ params.cursor = cursor
2196
+ }
2197
+
2198
+ const response = await this.client.get(`${this.baseURL}/${circleId}/moderation/mutes`, { params })
2199
+ return normalizeCollectionResponse(response.data, normalizeMuteRecord)
2200
+ } catch (error: unknown) {
2201
+ if (this.isHttpStatus(error, 404)) {
2202
+ return createEmptyPagination<CircleMuteRecord>()
2203
+ }
2204
+
2205
+ return await this.handleError(error)
2206
+ }
2207
+ }
2208
+
2209
+ public async unmuteMember(circleId: string, userId: string): Promise<void> {
2210
+ try {
2211
+ await this.client.delete(`${this.baseURL}/${circleId}/moderation/mutes/${userId}`)
2212
+ this.emitNotification({
2213
+ description: `Member unmuted in ${this.getCircleTerm()}.`,
2214
+ color: 'success',
2215
+ })
2216
+ } catch (error: unknown) {
2217
+ return await this.handleError(error)
2218
+ }
2219
+ }
2220
+
2221
+ public async reportMember(
2222
+ circleId: string,
2223
+ subjectUserId: string,
2224
+ category: string,
2225
+ notes?: string,
2226
+ ): Promise<{ report_id: string; status: string }> {
2227
+ try {
2228
+ const payload: Record<string, string> = {
2229
+ subject_user_id: subjectUserId,
2230
+ category: this.sanitizeInput(category),
2231
+ }
2232
+ if (typeof notes === 'string' && notes.trim().length > 0) {
2233
+ payload.notes = this.sanitizeInput(notes)
2234
+ }
2235
+
2236
+ const response = await this.client.post(`${this.baseURL}/${circleId}/moderation/reports`, payload)
2237
+ const data = readRecord(response.data, 'data') ?? {}
2238
+ const reportId = readString(data.report_id)
2239
+ const status = readString(data.status)
2240
+
2241
+ if (reportId === null || status === null) {
2242
+ throw new Error('Invalid moderation report payload')
2243
+ }
2244
+
2245
+ this.emitNotification({
2246
+ description: 'Report submitted for review.',
2247
+ color: 'success',
2248
+ })
2249
+
2250
+ return {
2251
+ report_id: reportId,
2252
+ status,
2253
+ }
2254
+ } catch (error: unknown) {
2255
+ return await this.handleError(error)
2256
+ }
2257
+ }
2258
+
2259
+ public async listModerationReports(
2260
+ circleId: string,
2261
+ cursor?: string | null,
2262
+ status?: CircleReportStatus,
2263
+ ): Promise<PaginationResponse<CircleModerationReport>> {
2264
+ try {
2265
+ const params: Record<string, string> = {}
2266
+ if (cursor) {
2267
+ params.cursor = cursor
2268
+ }
2269
+ if (status) {
2270
+ params.status = status
2271
+ }
2272
+
2273
+ const response = await this.client.get(`${this.baseURL}/${circleId}/moderation/reports`, { params })
2274
+ return normalizeCollectionResponse(response.data, normalizeModerationReport)
2275
+ } catch (error: unknown) {
2276
+ return await this.handleError(error)
2277
+ }
2278
+ }
2279
+
2280
+ public async resolveModerationReport(
2281
+ circleId: string,
2282
+ reportId: string,
2283
+ decision: CircleReportDecision,
2284
+ resolutionNotes?: string,
2285
+ ): Promise<{ report_id: string; status: string }> {
2286
+ try {
2287
+ const payload: Record<string, string> = { decision }
2288
+ if (typeof resolutionNotes === 'string' && resolutionNotes.trim().length > 0) {
2289
+ payload.resolution_notes = this.sanitizeInput(resolutionNotes)
2290
+ }
2291
+
2292
+ const response = await this.client.patch(`${this.baseURL}/${circleId}/moderation/reports/${reportId}`, payload)
2293
+ const data = readRecord(response.data, 'data') ?? {}
2294
+ const resolvedReportId = readString(data.report_id) ?? reportId
2295
+ const status = readString(data.status)
2296
+
2297
+ if (status === null) {
2298
+ throw new Error('Invalid moderation resolution payload')
2299
+ }
2300
+
2301
+ this.emitNotification({
2302
+ description: `Report ${decision === 'action' ? 'actioned' : 'dismissed'}.`,
2303
+ color: 'success',
2304
+ })
2305
+
2306
+ return {
2307
+ report_id: resolvedReportId,
2308
+ status,
2309
+ }
2310
+ } catch (error: unknown) {
2311
+ return await this.handleError(error)
2312
+ }
2313
+ }
2314
+
2315
+ public async getModerationAuditLog(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleModerationAuditLogEntry>> {
2316
+ try {
2317
+ const params: Record<string, string> = {}
2318
+ if (cursor) {
2319
+ params.cursor = cursor
2320
+ }
2321
+
2322
+ const response = await this.client.get(`${this.baseURL}/${circleId}/moderation/audit-log`, { params })
2323
+ return normalizeCollectionResponse(response.data, normalizeAuditLogEntry)
2324
+ } catch (error: unknown) {
2325
+ return await this.handleError(error)
2326
+ }
2327
+ }
2328
+
2329
+ public async listModerationSubjects(
2330
+ circleId: string,
2331
+ subjectType: CircleModerationSubjectType,
2332
+ cursor?: string | null,
2333
+ state?: CircleModerationSubjectState | 'all',
2334
+ ): Promise<PaginationResponse<CircleModerationSubject>> {
2335
+ try {
2336
+ const params: Record<string, string> = {
2337
+ subject_type: subjectType,
2338
+ }
2339
+
2340
+ if (cursor) {
2341
+ params.cursor = cursor
2342
+ }
2343
+
2344
+ if (state) {
2345
+ params.state = state
2346
+ }
2347
+
2348
+ const response = await this.client.get(`${this.baseURL}/${circleId}/moderation/subjects`, { params })
2349
+ return normalizeCollectionResponse(response.data, normalizeModerationSubject)
2350
+ } catch (error: unknown) {
2351
+ return await this.handleError(error)
2352
+ }
2353
+ }
2354
+
2355
+ public async actOnModerationSubject(
2356
+ circleId: string,
2357
+ subjectType: CircleModerationSubjectType,
2358
+ subjectId: string,
2359
+ action: CircleModerationSubjectAction,
2360
+ reason?: string,
2361
+ ): Promise<void> {
2362
+ try {
2363
+ const payload: Record<string, string> = { action }
2364
+ if (typeof reason === 'string' && reason.trim().length > 0) {
2365
+ payload.reason = this.sanitizeInput(reason)
2366
+ }
2367
+
2368
+ await this.client.post(`${this.baseURL}/${circleId}/moderation/subjects/${subjectType}/${subjectId}/actions`, payload)
2369
+
2370
+ this.emitNotification({
2371
+ description: `Moderation action "${action}" applied.`,
2372
+ color: 'success',
2373
+ })
2374
+ } catch (error: unknown) {
2375
+ return await this.handleError(error)
2376
+ }
2377
+ }
2378
+
2379
+ public async listAutomodRules(circleId: string, cursor?: string | null): Promise<PaginationResponse<CircleAutomodRule>> {
2380
+ try {
2381
+ const params: Record<string, string> = {}
2382
+ if (cursor) {
2383
+ params.cursor = cursor
2384
+ }
2385
+
2386
+ const response = await this.client.get(`${this.baseURL}/${circleId}/automod-rules`, { params })
2387
+ return normalizeCollectionResponse(response.data, normalizeAutomodRule)
2388
+ } catch (error: unknown) {
2389
+ return await this.handleError(error)
2390
+ }
2391
+ }
2392
+
2393
+ public async getAutomodRule(circleId: string, ruleId: string): Promise<CircleAutomodRule> {
2394
+ try {
2395
+ const response = await this.client.get(`${this.baseURL}/${circleId}/automod-rules/${ruleId}`)
2396
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
2397
+ const normalized = normalizeAutomodRule(data)
2398
+
2399
+ if (normalized !== null) {
2400
+ return normalized
2401
+ }
2402
+
2403
+ throw new Error('Invalid automod rule payload')
2404
+ } catch (error: unknown) {
2405
+ return await this.handleError(error)
2406
+ }
2407
+ }
2408
+
2409
+ public async createAutomodRule(circleId: string, input: CircleAutomodRuleInput): Promise<CircleAutomodRule> {
2410
+ try {
2411
+ const response = await this.client.post(`${this.baseURL}/${circleId}/automod-rules`, this.sanitizeInput(input))
2412
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
2413
+ const normalized = normalizeAutomodRule(data)
2414
+
2415
+ if (normalized !== null) {
2416
+ this.emitNotification({
2417
+ description: 'Automoderation rule created.',
2418
+ color: 'success',
2419
+ })
2420
+ return normalized
2421
+ }
2422
+
2423
+ throw new Error('Invalid automod rule payload')
2424
+ } catch (error: unknown) {
2425
+ return await this.handleError(error)
2426
+ }
2427
+ }
2428
+
2429
+ public async updateAutomodRule(circleId: string, ruleId: string, input: Partial<CircleAutomodRuleInput>): Promise<CircleAutomodRule> {
2430
+ try {
2431
+ const response = await this.client.patch(`${this.baseURL}/${circleId}/automod-rules/${ruleId}`, this.sanitizeInput(input))
2432
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
2433
+ const normalized = normalizeAutomodRule(data)
2434
+
2435
+ if (normalized !== null) {
2436
+ this.emitNotification({
2437
+ description: 'Automoderation rule updated.',
2438
+ color: 'success',
2439
+ })
2440
+ return normalized
2441
+ }
2442
+
2443
+ throw new Error('Invalid automod rule payload')
2444
+ } catch (error: unknown) {
2445
+ return await this.handleError(error)
2446
+ }
2447
+ }
2448
+
2449
+ public async deleteAutomodRule(circleId: string, ruleId: string): Promise<void> {
2450
+ try {
2451
+ await this.client.delete(`${this.baseURL}/${circleId}/automod-rules/${ruleId}`)
2452
+ this.emitNotification({
2453
+ description: 'Automoderation rule deleted.',
2454
+ color: 'success',
2455
+ })
2456
+ } catch (error: unknown) {
2457
+ return await this.handleError(error)
2458
+ }
2459
+ }
2460
+
2461
+ public async getManagementBootstrap(circleId: string): Promise<CircleManagementBootstrap> {
2462
+ const identifier = this.sanitizeInput(circleId)
2463
+ let resolvedCircle: Circle | null = null
2464
+ const bootstrapIdentifier = this.isUuid(identifier)
2465
+ ? identifier
2466
+ : await (async (): Promise<string> => {
2467
+ resolvedCircle = await this.get(identifier)
2468
+ return resolvedCircle.id
2469
+ })()
2470
+
2471
+ try {
2472
+ const response = await this.client.get(`${this.baseURL}/${bootstrapIdentifier}/management/bootstrap`)
2473
+ const data = readRecord(response.data, 'data')
2474
+ const bootstrapCircle = data?.circle
2475
+ const bootstrapManagementSections = normalizeManagementSections(data?.management_sections)
2476
+ const directCircle = normalizeCircle(bootstrapCircle ?? data)
2477
+ const mergedCircleSource = directCircle === null
2478
+ ? {
2479
+ ...(resolvedCircle ?? await this.get(bootstrapIdentifier)),
2480
+ ...(bootstrapCircle ?? {}),
2481
+ actor_capabilities: data?.actor_capabilities,
2482
+ management_sections: bootstrapManagementSections.length > 0
2483
+ ? bootstrapManagementSections
2484
+ : undefined,
2485
+ }
2486
+ : {
2487
+ ...directCircle,
2488
+ actor_capabilities: data?.actor_capabilities ?? directCircle.actor_capabilities,
2489
+ management_sections: bootstrapManagementSections.length > 0
2490
+ ? bootstrapManagementSections
2491
+ : directCircle.management_sections,
2492
+ }
2493
+ const circle = normalizeCircle(mergedCircleSource)
2494
+
2495
+ if (circle !== null) {
2496
+ const actor = circle.actor ?? buildActorContext(circle)
2497
+ return {
2498
+ circle,
2499
+ actor,
2500
+ management_sections: bootstrapManagementSections.length > 0
2501
+ ? bootstrapManagementSections
2502
+ : actor.managementSections,
2503
+ members: normalizeCollectionResponse(data?.members ?? {}, normalizeCircleMember),
2504
+ requests: normalizeCollectionResponse(data?.requests ?? {}, normalizeJoinRequest),
2505
+ roles: Array.isArray(data?.roles)
2506
+ ? normalizeInlineCollection(data.roles, normalizeRoleDefinition)
2507
+ : normalizeCollectionResponse(data?.roles ?? {}, normalizeRoleDefinition),
2508
+ counts: normalizeManagementCounts(data?.counts),
2509
+ permission_catalog: Array.isArray(data?.permission_catalog)
2510
+ ? data.permission_catalog
2511
+ .map((entry) => normalizePermissionCatalogEntry(entry))
2512
+ .filter((entry): entry is CirclePermissionCatalogEntry => entry !== null)
2513
+ : [],
2514
+ reports: normalizeCollectionResponse(data?.reports ?? {}, normalizeModerationReport),
2515
+ bans: normalizeCollectionResponse(data?.bans ?? {}, normalizeBanRecord),
2516
+ mutes: normalizeCollectionResponse(data?.mutes ?? {}, normalizeMuteRecord),
2517
+ audit: normalizeCollectionResponse(data?.audit ?? {}, normalizeAuditLogEntry),
2518
+ automod: normalizeCollectionResponse(data?.automod ?? {}, normalizeAutomodRule),
2519
+ }
2520
+ }
2521
+ } catch (error: unknown) {
2522
+ if (!this.isHttpStatus(error, 404)) {
2523
+ return await this.handleError(error)
2524
+ }
2525
+ }
2526
+
2527
+ const circle = resolvedCircle ?? await this.get(identifier)
2528
+ const actor = circle.actor ?? buildActorContext(circle)
2529
+
2530
+ if (!actor.capabilities.canViewManagement) {
2531
+ return {
2532
+ circle,
2533
+ actor,
2534
+ management_sections: actor.managementSections,
2535
+ members: createEmptyPagination<CircleMember>(),
2536
+ requests: createEmptyPagination<JoinRequest>(),
2537
+ roles: createEmptyPagination<CircleRoleDefinition>(),
2538
+ counts: createEmptyManagementCounts(),
2539
+ permission_catalog: [],
2540
+ reports: createEmptyPagination<CircleModerationReport>(),
2541
+ bans: createEmptyPagination<CircleBanRecord>(),
2542
+ mutes: createEmptyPagination<CircleMuteRecord>(),
2543
+ audit: createEmptyPagination<CircleModerationAuditLogEntry>(),
2544
+ automod: createEmptyPagination<CircleAutomodRule>(),
2545
+ }
2546
+ }
2547
+
2548
+ const requestsPromise = actor.capabilities.canReviewRequests
2549
+ ? this.listJoinRequests(circle.id, null, 'pending')
2550
+ : Promise.resolve(createEmptyPagination<JoinRequest>())
2551
+
2552
+ const [
2553
+ members,
2554
+ requests,
2555
+ roles,
2556
+ reports,
2557
+ bans,
2558
+ mutes,
2559
+ audit,
2560
+ automod,
2561
+ ] = await Promise.all([
2562
+ this.getMembers(circle.id),
2563
+ requestsPromise,
2564
+ this.listRoles(circle.id),
2565
+ this.listModerationReports(circle.id, null, 'pending'),
2566
+ this.listBans(circle.id),
2567
+ this.listMutes(circle.id),
2568
+ this.getModerationAuditLog(circle.id),
2569
+ this.listAutomodRules(circle.id),
2570
+ ])
2571
+
2572
+ return {
2573
+ circle,
2574
+ actor,
2575
+ management_sections: actor.managementSections,
2576
+ members,
2577
+ requests,
2578
+ roles,
2579
+ counts: {
2580
+ members: circle.member_count ?? members.data.length,
2581
+ requests_pending: requests.data.length,
2582
+ reports_pending: reports.data.length,
2583
+ roles: roles.data.length,
2584
+ bans_active: bans.data.length,
2585
+ mutes_active: mutes.data.length,
2586
+ },
2587
+ permission_catalog: [],
2588
+ reports,
2589
+ bans,
2590
+ mutes,
2591
+ audit,
2592
+ automod,
2593
+ }
2594
+ }
2595
+
2596
+ public async searchInviteCandidates(circleId: string, query: string): Promise<CircleInviteSearchUser[]> {
2597
+ void circleId
2598
+
2599
+ try {
2600
+ const sanitizedQuery = query.trim()
2601
+ if (sanitizedQuery.length < 2) {
2602
+ return []
2603
+ }
2604
+
2605
+ const response = await this.client.get('/v1/search/aggregate', {
2606
+ params: {
2607
+ q: sanitizedQuery,
2608
+ 'types[]': 'user',
2609
+ limit: 10,
2610
+ },
2611
+ })
2612
+
2613
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
2614
+ const results = readRecord(data, 'results')
2615
+ const byType = readRecord(results, 'by_type')
2616
+ const users = Array.isArray(byType?.user) ? byType.user : []
2617
+
2618
+ return users
2619
+ .map((entry): CircleInviteSearchUser | null => {
2620
+ if (!isRecord(entry)) {
2621
+ return null
2622
+ }
2623
+
2624
+ const id = readString(entry.id)
2625
+ const name = readString(entry.title)
2626
+ const meta = readRecord(entry, 'meta')
2627
+ const handle = readString(meta?.handle)
2628
+
2629
+ if (id === null || name === null || handle === null) {
2630
+ return null
2631
+ }
2632
+
2633
+ return {
2634
+ id,
2635
+ name,
2636
+ handle,
2637
+ avatar_url: readNullableString(meta?.avatar_url),
2638
+ }
2639
+ })
2640
+ .filter((entry): entry is CircleInviteSearchUser => entry !== null)
2641
+ } catch (error: unknown) {
2642
+ return await this.handleError(error)
2643
+ }
2644
+ }
2645
+
2646
+ public async createInviteLink(
2647
+ circleId: string,
2648
+ options?: CircleInviteLinkOptions,
2649
+ requestOptions?: CircleInviteLinkRequestOptions,
2650
+ ): Promise<InviteLink> {
2651
+ try {
2652
+ const body: Record<string, number | string> = {}
2653
+ if (typeof options?.max_uses === 'number') {
2654
+ body.max_uses = options.max_uses
2655
+ }
2656
+ if (typeof options?.expires_in_minutes === 'number') {
2657
+ body.expires_in_minutes = options.expires_in_minutes
2658
+ }
2659
+ if (typeof options?.target_user_id === 'string' && options.target_user_id.trim() !== '') {
2660
+ body.target_user_id = options.target_user_id
2661
+ }
2662
+
2663
+ const response = await this.client.post(`${this.baseURL}/${circleId}/invite-links`, body)
2664
+ const data = readRecord(response.data, 'data') ?? (isRecord(response.data) ? response.data : null)
2665
+ const normalized = normalizeInviteLink(data)
2666
+
2667
+ if (normalized !== null) {
2668
+ if (requestOptions?.showSuccessToast !== false) {
2669
+ this.emitNotification({
2670
+ description: `Invite link created for ${this.getCircleTerm()}!`,
2671
+ color: 'success',
2672
+ })
2673
+ }
2674
+ return normalized
2675
+ }
2676
+
2677
+ throw new Error('Invalid invite link payload')
2678
+ } catch (error: unknown) {
2679
+ return await this.handleError(error)
2680
+ }
2681
+ }
2682
+
2683
+ public async joinViaInvite(token: string): Promise<{ circle_id: string; requested: boolean }> {
2684
+ try {
2685
+ const response = await this.client.post(`/v1/circle-invites/${encodeURIComponent(token)}/join`)
2686
+ const data = readRecord(response.data, 'data') ?? {}
2687
+ const circleId = readString(data.circle_id)
2688
+ const requested = readBoolean(data.requested)
2689
+
2690
+ if (circleId === null || requested === null) {
2691
+ throw new Error('Invalid invite accept payload')
2692
+ }
2693
+
2694
+ return {
2695
+ circle_id: circleId,
2696
+ requested,
2697
+ }
2698
+ } catch (error: unknown) {
2699
+ return await this.handleError(error)
2700
+ }
2701
+ }
2702
+
2703
+ public async declineInvite(token: string): Promise<void> {
2704
+ try {
2705
+ await this.client.post(`/v1/circle-invites/${encodeURIComponent(token)}/decline`)
2706
+ } catch (error: unknown) {
2707
+ return await this.handleError(error)
2708
+ }
2709
+ }
2710
+
2711
+ public async checkSlugAvailability(slug: string): Promise<boolean> {
2712
+ try {
2713
+ const sanitizedSlug = this.sanitizeInput(slug)
2714
+ const response = await this.client.get(`${this.baseURL}/check-slug`, {
2715
+ params: { slug: sanitizedSlug },
2716
+ })
2717
+
2718
+ const data = readRecord(response.data, 'data')
2719
+ const payloadAvailability = readBoolean(data?.available)
2720
+ const rootAvailability = readBoolean(isRecord(response.data) ? response.data.available : undefined)
2721
+
2722
+ return payloadAvailability ?? rootAvailability ?? true
2723
+ } catch (error: unknown) {
2724
+ if (this.isHttpStatus(error, 404)) {
2725
+ return true
2726
+ }
2727
+
2728
+ return await this.handleError(error)
2729
+ }
2730
+ }
2731
+
2732
+ public generateSlug(name: string): string {
2733
+ if (name.trim().length === 0) {
2734
+ return ''
2735
+ }
2736
+
2737
+ return this.sanitizeInput(name)
2738
+ .toLowerCase()
2739
+ .replace(/[^a-z0-9\s-]/g, '')
2740
+ .replace(/\s+/g, '-')
2741
+ .replace(/-+/g, '-')
2742
+ .replace(/^-|-$/g, '')
2743
+ .substring(0, 140)
2744
+ }
2745
+ }
2746
+
2747
+ export function createCirclesService(config: CirclesServiceConfig) {
2748
+ const {
2749
+ client,
2750
+ sanitizeString = defaultSanitizeString,
2751
+ onNotify,
2752
+ onUnauthorized,
2753
+ getCircleTerm = defaultGetCircleTerm,
2754
+ onError,
2755
+ } = config
2756
+
2757
+ return new CirclesService(
2758
+ client,
2759
+ sanitizeString,
2760
+ onNotify,
2761
+ onUnauthorized,
2762
+ getCircleTerm,
2763
+ onError,
2764
+ )
2765
+ }
2766
+
2767
+ export type CirclesServiceInstance = ReturnType<typeof createCirclesService>