@codingfactory/socialkit-vue 0.1.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.
Files changed (40) hide show
  1. package/dist/index.d.ts +9 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/circles.d.ts +470 -0
  6. package/dist/services/circles.d.ts.map +1 -0
  7. package/dist/services/circles.js +1924 -0
  8. package/dist/services/circles.js.map +1 -0
  9. package/dist/services/identity.d.ts +29 -0
  10. package/dist/services/identity.d.ts.map +1 -0
  11. package/dist/services/identity.js +150 -0
  12. package/dist/services/identity.js.map +1 -0
  13. package/dist/stores/auth.d.ts +3 -0
  14. package/dist/stores/auth.d.ts.map +1 -1
  15. package/dist/stores/circles.d.ts +4283 -0
  16. package/dist/stores/circles.d.ts.map +1 -0
  17. package/dist/stores/circles.js +1670 -0
  18. package/dist/stores/circles.js.map +1 -0
  19. package/dist/types/api.d.ts +19 -0
  20. package/dist/types/api.d.ts.map +1 -1
  21. package/dist/types/auth.d.ts +34 -0
  22. package/dist/types/auth.d.ts.map +1 -0
  23. package/dist/types/auth.js +8 -0
  24. package/dist/types/auth.js.map +1 -0
  25. package/dist/types/identity.d.ts +34 -0
  26. package/dist/types/identity.d.ts.map +1 -0
  27. package/dist/types/identity.js +5 -0
  28. package/dist/types/identity.js.map +1 -0
  29. package/dist/types/user.d.ts +1 -0
  30. package/dist/types/user.d.ts.map +1 -1
  31. package/dist/types/user.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/index.ts +105 -0
  34. package/src/services/circles.ts +2767 -0
  35. package/src/services/identity.ts +281 -0
  36. package/src/stores/circles.ts +2114 -0
  37. package/src/types/api.ts +20 -0
  38. package/src/types/auth.ts +39 -0
  39. package/src/types/identity.ts +41 -0
  40. package/src/types/user.ts +1 -0
@@ -0,0 +1,2114 @@
1
+ /**
2
+ * Generic circles store factory for SocialKit-powered frontends.
3
+ */
4
+
5
+ import { defineStore } from 'pinia'
6
+ import type {
7
+ Circle,
8
+ CircleActorContext,
9
+ CircleAutomodRule,
10
+ CircleAutomodRuleInput,
11
+ CircleBanRecord,
12
+ CircleCreateInput,
13
+ CircleFilters,
14
+ CircleInviteLinkOptions,
15
+ CircleManagementBootstrap,
16
+ CircleManagementSection,
17
+ CircleMember,
18
+ CircleMemberRoleAssignmentResult,
19
+ CircleModerationAuditLogEntry,
20
+ CircleModerationReport,
21
+ CircleMuteMemberOptions,
22
+ CircleMuteRecord,
23
+ CircleReportDecision,
24
+ CircleRole,
25
+ CircleRoleDefinition,
26
+ CircleRoleDefinitionInput,
27
+ CircleRoleDefinitionUpdateInput,
28
+ CircleUpdateInput,
29
+ CirclesServiceInstance,
30
+ InviteLink,
31
+ JoinRequest,
32
+ } from '../services/circles.js'
33
+
34
+ interface CursorPaginationState {
35
+ cursor: string | null
36
+ hasMore: boolean
37
+ }
38
+
39
+ type ManagementCollectionKey =
40
+ | 'members'
41
+ | 'requests'
42
+ | 'roles'
43
+ | 'reports'
44
+ | 'bans'
45
+ | 'mutes'
46
+ | 'audit'
47
+ | 'automod'
48
+
49
+ interface ManagementSectionLoadingState {
50
+ members?: boolean
51
+ requests?: boolean
52
+ roles?: boolean
53
+ reports?: boolean
54
+ bans?: boolean
55
+ mutes?: boolean
56
+ audit?: boolean
57
+ automod?: boolean
58
+ }
59
+
60
+ interface ManagementSectionErrorState {
61
+ members?: string | null
62
+ requests?: string | null
63
+ roles?: string | null
64
+ reports?: string | null
65
+ bans?: string | null
66
+ mutes?: string | null
67
+ audit?: string | null
68
+ automod?: string | null
69
+ }
70
+
71
+ interface CircleRoleAssignment {
72
+ id: string
73
+ user_id: string
74
+ role: CircleRole
75
+ assigned_at: string | null
76
+ user?: CircleMember['user']
77
+ }
78
+
79
+ interface CircleManagementCounts {
80
+ [key: string]: number
81
+ members: number
82
+ requests_pending: number
83
+ reports_pending: number
84
+ roles: number
85
+ bans_active: number
86
+ mutes_active: number
87
+ }
88
+
89
+ interface CircleManagementPermissionCatalogEntry {
90
+ key: string
91
+ label: string
92
+ description: string
93
+ module: string
94
+ owner_only: boolean
95
+ surfaced: boolean
96
+ section: string
97
+ }
98
+
99
+ type CircleMemberState = CircleMember
100
+
101
+ interface CircleRoleStoreService {
102
+ fetchRoleDefinitions(circleId: string, cursor?: string | null): Promise<unknown>
103
+ listRoleDefinitions(circleId: string, cursor?: string | null): Promise<unknown>
104
+ listRoles(circleId: string, cursor?: string | null): Promise<unknown>
105
+ createRoleDefinition(circleId: string, input: CircleRoleDefinitionInput): Promise<unknown>
106
+ createRole(circleId: string, input: CircleRoleDefinitionInput): Promise<unknown>
107
+ updateRoleDefinition(circleId: string, roleId: string, input: CircleRoleDefinitionUpdateInput): Promise<unknown>
108
+ updateRole(circleId: string, roleId: string, input: CircleRoleDefinitionUpdateInput): Promise<unknown>
109
+ replaceRoleDefinitionPermissions(circleId: string, roleId: string, permissions: string[]): Promise<unknown>
110
+ replaceRolePermissions(circleId: string, roleId: string, permissions: string[]): Promise<unknown>
111
+ archiveRoleDefinition(circleId: string, roleId: string): Promise<void>
112
+ archiveRole(circleId: string, roleId: string): Promise<void>
113
+ assignMemberRoles(circleId: string, userId: string, roleIds: string[]): Promise<unknown>
114
+ removeMemberRole(circleId: string, userId: string, roleId: string): Promise<void>
115
+ }
116
+
117
+ export interface CirclesStoreConfig {
118
+ storeId?: string
119
+ circlesService: CirclesServiceInstance
120
+ getErrorMessage: (error: unknown) => string
121
+ persist?: Record<string, unknown>
122
+ }
123
+
124
+ const DEFAULT_FILTER_KEY = 'all'
125
+ const DEFAULT_MANAGEMENT_COUNTS: CircleManagementCounts = {
126
+ members: 0,
127
+ requests_pending: 0,
128
+ reports_pending: 0,
129
+ roles: 0,
130
+ bans_active: 0,
131
+ mutes_active: 0,
132
+ }
133
+
134
+ const defaultPaginationState = (): CursorPaginationState => ({
135
+ cursor: null,
136
+ hasMore: true,
137
+ })
138
+
139
+ const emptyPaginationState = (): CursorPaginationState => ({
140
+ cursor: null,
141
+ hasMore: false,
142
+ })
143
+
144
+ const toCursorPaginationState = (response: {
145
+ meta?: {
146
+ pagination?: {
147
+ next_cursor?: string | null
148
+ has_more?: boolean
149
+ }
150
+ }
151
+ }): CursorPaginationState => ({
152
+ cursor: response.meta?.pagination?.next_cursor ?? null,
153
+ hasMore: response.meta?.pagination?.has_more ?? Boolean(response.meta?.pagination?.next_cursor),
154
+ })
155
+
156
+ const normalizeFilters = (filters: CircleFilters): CircleFilters => {
157
+ const normalized: CircleFilters = {}
158
+
159
+ if (typeof filters.visibility === 'string' && filters.visibility.length > 0) {
160
+ normalized.visibility = filters.visibility
161
+ }
162
+
163
+ if (typeof filters.membership === 'string' && filters.membership.length > 0) {
164
+ normalized.membership = filters.membership
165
+ }
166
+
167
+ if (typeof filters.search === 'string') {
168
+ const search = filters.search.trim()
169
+ if (search.length > 0) {
170
+ normalized.search = search
171
+ }
172
+ }
173
+
174
+ return normalized
175
+ }
176
+
177
+ const buildFilterKey = (filters: CircleFilters): string => {
178
+ const normalized = normalizeFilters(filters)
179
+ const entries = Object.entries(normalized)
180
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
181
+
182
+ if (entries.length === 0) {
183
+ return DEFAULT_FILTER_KEY
184
+ }
185
+
186
+ return entries.map(([key, value]) => `${key}:${value}`).join('|')
187
+ }
188
+
189
+ const mergeUniqueCircles = (existing: Circle[], incoming: Circle[]): Circle[] => {
190
+ const existingIds = new Set(existing.map(circle => circle.id))
191
+ const merged = [...existing]
192
+
193
+ for (const circle of incoming) {
194
+ if (existingIds.has(circle.id)) {
195
+ continue
196
+ }
197
+
198
+ existingIds.add(circle.id)
199
+ merged.push(circle)
200
+ }
201
+
202
+ return merged
203
+ }
204
+
205
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
206
+ return typeof value === 'object' && value !== null
207
+ }
208
+
209
+ const readRecord = (value: unknown, key: string): Record<string, unknown> | null => {
210
+ if (!isRecord(value)) {
211
+ return null
212
+ }
213
+
214
+ const entry = value[key]
215
+ return isRecord(entry) ? entry : null
216
+ }
217
+
218
+ const readString = (value: unknown): string | null => {
219
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
220
+ }
221
+
222
+ const readNullableString = (value: unknown): string | null => {
223
+ if (value === null || value === undefined) {
224
+ return null
225
+ }
226
+
227
+ return readString(value)
228
+ }
229
+
230
+ const readBoolean = (value: unknown): boolean | null => {
231
+ return typeof value === 'boolean' ? value : null
232
+ }
233
+
234
+ const readNumber = (value: unknown): number | null => {
235
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
236
+ }
237
+
238
+ const isCircleVisibility = (value: unknown): value is Circle['visibility'] => {
239
+ return value === 'public' || value === 'closed' || value === 'secret'
240
+ }
241
+
242
+ const isCircleEntry = (value: unknown): value is Circle => {
243
+ if (!isRecord(value)) {
244
+ return false
245
+ }
246
+
247
+ return typeof value.id === 'string'
248
+ && value.id.length > 0
249
+ && typeof value.name === 'string'
250
+ && value.name.length > 0
251
+ && isCircleVisibility(value.visibility)
252
+ }
253
+
254
+ const sanitizeCirclesPayload = (payload: unknown): Circle[] => {
255
+ if (!Array.isArray(payload)) {
256
+ return []
257
+ }
258
+
259
+ return payload.filter(isCircleEntry)
260
+ }
261
+
262
+ const sanitizeCircleCollection = (circles: Circle[]): Circle[] => {
263
+ return sanitizeCirclesPayload(circles)
264
+ }
265
+
266
+ const sanitizePermissionKeys = (value: unknown): string[] => {
267
+ if (!Array.isArray(value)) {
268
+ return []
269
+ }
270
+
271
+ return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
272
+ }
273
+
274
+ const normalizeRoleDefinition = (value: unknown): CircleRoleDefinition | null => {
275
+ if (!isRecord(value)) {
276
+ return null
277
+ }
278
+
279
+ const id = readString(value.id)
280
+ const slug = readString(value.slug)
281
+ const name = readString(value.name)
282
+
283
+ if (id === null || slug === null || name === null) {
284
+ return null
285
+ }
286
+
287
+ return {
288
+ id,
289
+ slug,
290
+ name,
291
+ description: readNullableString(value.description),
292
+ color: readNullableString(value.color),
293
+ is_system: readBoolean(value.is_system) ?? false,
294
+ is_assignable: readBoolean(value.is_assignable) ?? true,
295
+ member_count: readNumber(value.member_count) ?? 0,
296
+ permissions: sanitizePermissionKeys(value.permissions),
297
+ }
298
+ }
299
+
300
+ const sanitizeRoleDefinitions = (value: unknown): CircleRoleDefinition[] => {
301
+ if (!Array.isArray(value)) {
302
+ return []
303
+ }
304
+
305
+ return value
306
+ .map((entry) => normalizeRoleDefinition(entry))
307
+ .filter((entry): entry is CircleRoleDefinition => entry !== null)
308
+ }
309
+
310
+ const normalizePermissionCatalogEntry = (value: unknown): CircleManagementPermissionCatalogEntry | null => {
311
+ if (!isRecord(value)) {
312
+ return null
313
+ }
314
+
315
+ const key = readString(value.key)
316
+ const label = readString(value.label)
317
+ const description = readString(value.description)
318
+ const module = readString(value.module)
319
+ const section = readString(value.section)
320
+
321
+ if (key === null || label === null || description === null || module === null || section === null) {
322
+ return null
323
+ }
324
+
325
+ return {
326
+ key,
327
+ label,
328
+ description,
329
+ module,
330
+ owner_only: readBoolean(value.owner_only) ?? false,
331
+ surfaced: readBoolean(value.surfaced) ?? false,
332
+ section,
333
+ }
334
+ }
335
+
336
+ const sanitizePermissionCatalog = (value: unknown): CircleManagementPermissionCatalogEntry[] => {
337
+ if (!Array.isArray(value)) {
338
+ return []
339
+ }
340
+
341
+ return value
342
+ .map((entry) => normalizePermissionCatalogEntry(entry))
343
+ .filter((entry): entry is CircleManagementPermissionCatalogEntry => entry !== null)
344
+ }
345
+
346
+ const sanitizeManagementCounts = (value: unknown): CircleManagementCounts => {
347
+ const counts: CircleManagementCounts = { ...DEFAULT_MANAGEMENT_COUNTS }
348
+
349
+ if (!isRecord(value)) {
350
+ return counts
351
+ }
352
+
353
+ for (const [key, entry] of Object.entries(value)) {
354
+ const numericValue = readNumber(entry)
355
+ if (numericValue !== null) {
356
+ counts[key] = numericValue
357
+ }
358
+ }
359
+
360
+ return counts
361
+ }
362
+
363
+ const extractItems = (value: unknown): unknown[] => {
364
+ if (Array.isArray(value)) {
365
+ return value
366
+ }
367
+
368
+ if (!isRecord(value)) {
369
+ return []
370
+ }
371
+
372
+ const data = readRecord(value, 'data')
373
+ const dataItems = data?.items
374
+ if (Array.isArray(dataItems)) {
375
+ return dataItems
376
+ }
377
+
378
+ const rootItems = value.items
379
+ if (Array.isArray(rootItems)) {
380
+ return rootItems
381
+ }
382
+
383
+ const rootData = value.data
384
+ if (Array.isArray(rootData)) {
385
+ return rootData
386
+ }
387
+
388
+ return []
389
+ }
390
+
391
+ const toUnknownCursorPaginationState = (value: unknown): CursorPaginationState => {
392
+ if (!isRecord(value)) {
393
+ return emptyPaginationState()
394
+ }
395
+
396
+ const meta = readRecord(value, 'meta')
397
+ const pagination = readRecord(meta, 'pagination')
398
+ const nextCursor = readNullableString(pagination?.next_cursor)
399
+
400
+ return {
401
+ cursor: nextCursor,
402
+ hasMore: readBoolean(pagination?.has_more) ?? (nextCursor !== null),
403
+ }
404
+ }
405
+
406
+ const extractRoleDefinitions = (value: unknown): CircleRoleDefinition[] => {
407
+ if (isRecord(value) && 'roles' in value) {
408
+ return extractRoleDefinitions(value.roles)
409
+ }
410
+
411
+ return sanitizeRoleDefinitions(extractItems(value))
412
+ }
413
+
414
+ const extractManagementCounts = (value: unknown): CircleManagementCounts => {
415
+ return isRecord(value) ? sanitizeManagementCounts(value.counts) : { ...DEFAULT_MANAGEMENT_COUNTS }
416
+ }
417
+
418
+ const extractManagementPermissionCatalog = (value: unknown): CircleManagementPermissionCatalogEntry[] => {
419
+ return isRecord(value) ? sanitizePermissionCatalog(value.permission_catalog) : []
420
+ }
421
+
422
+ const extractAssignedRoles = (member: CircleMemberState): CircleRoleDefinition[] => {
423
+ return sanitizeRoleDefinitions(member.assigned_roles)
424
+ }
425
+
426
+ const buildEffectivePermissions = (assignedRoles: CircleRoleDefinition[]): string[] => {
427
+ return [...new Set(assignedRoles.flatMap((role) => role.permissions))]
428
+ }
429
+
430
+ const deriveDisplayRole = (
431
+ member: CircleMemberState,
432
+ assignedRoles: CircleRoleDefinition[] = extractAssignedRoles(member),
433
+ ): CircleRole => {
434
+ const membershipRole = member.membership_role ?? (member.role === 'owner' ? 'owner' : 'member')
435
+ if (membershipRole === 'owner') {
436
+ return 'owner'
437
+ }
438
+
439
+ if (assignedRoles.some((role) => role.slug === 'group-admin')) {
440
+ return 'admin'
441
+ }
442
+
443
+ if (assignedRoles.length > 0) {
444
+ return 'moderator'
445
+ }
446
+
447
+ return member.role === 'owner' ? 'owner' : member.role
448
+ }
449
+
450
+ const deriveRoleAssignmentsFromMembers = (members: CircleMemberState[]): CircleRoleAssignment[] => {
451
+ return members.map((member) => {
452
+ const roleAssignment: CircleRoleAssignment = {
453
+ id: member.id,
454
+ user_id: member.user_id,
455
+ role: deriveDisplayRole(member),
456
+ assigned_at: member.joined_at,
457
+ }
458
+
459
+ if (member.user) {
460
+ roleAssignment.user = member.user
461
+ }
462
+
463
+ return roleAssignment
464
+ })
465
+ }
466
+
467
+ const getRoleStoreService = (circlesService: CirclesServiceInstance): Partial<CircleRoleStoreService> => {
468
+ // The shared service layer is mid-migration; runtime method checks let the store
469
+ // support both the legacy member-role API and the new RBAC role-definition API.
470
+ return circlesService as unknown as Partial<CircleRoleStoreService>
471
+ }
472
+
473
+ interface CirclesState {
474
+ circles: Circle[]
475
+ currentCircle: Circle | null
476
+ members: Record<string, CircleMemberState[]>
477
+ joinRequests: Record<string, JoinRequest[]>
478
+ joinRequestsPagination: Record<string, CursorPaginationState>
479
+ managementActors: Record<string, CircleActorContext>
480
+ managementSections: Record<string, CircleManagementSection[]>
481
+ managementBootstrapLoading: Record<string, boolean>
482
+ managementBootstrapError: Record<string, string | null>
483
+ managementSectionLoading: Record<string, ManagementSectionLoadingState>
484
+ managementSectionErrors: Record<string, ManagementSectionErrorState>
485
+ managementBootstrappedAt: Record<string, string>
486
+ roleDefinitions: Record<string, CircleRoleDefinition[]>
487
+ roleDefinitionsPagination: Record<string, CursorPaginationState>
488
+ managementCounts: Record<string, CircleManagementCounts>
489
+ managementPermissionCatalog: Record<string, CircleManagementPermissionCatalogEntry[]>
490
+ // Compatibility cache for legacy consumers still reading per-member display roles.
491
+ roleAssignments: Record<string, CircleRoleAssignment[]>
492
+ roleAssignmentsPagination: Record<string, CursorPaginationState>
493
+ reports: Record<string, CircleModerationReport[]>
494
+ reportsPagination: Record<string, CursorPaginationState>
495
+ bans: Record<string, CircleBanRecord[]>
496
+ bansPagination: Record<string, CursorPaginationState>
497
+ mutes: Record<string, CircleMuteRecord[]>
498
+ mutesPagination: Record<string, CursorPaginationState>
499
+ auditLog: Record<string, CircleModerationAuditLogEntry[]>
500
+ auditLogPagination: Record<string, CursorPaginationState>
501
+ automodRules: Record<string, CircleAutomodRule[]>
502
+ automodRulesPagination: Record<string, CursorPaginationState>
503
+ pendingRequestCircleIds: string[]
504
+ loading: boolean
505
+ error: string | null
506
+ pagination: {
507
+ cursor: string | null
508
+ hasMore: boolean
509
+ }
510
+ activeFilterKey: string
511
+ circlesByFilter: Record<string, Circle[]>
512
+ paginationByFilter: Record<string, CursorPaginationState>
513
+ fetchedFilterKeys: Record<string, boolean>
514
+ membersPagination: Record<string, {
515
+ cursor: string | null
516
+ hasMore: boolean
517
+ }>
518
+ filters: CircleFilters
519
+ }
520
+
521
+ export type CirclesStoreReturn = ReturnType<ReturnType<typeof createCirclesStoreDefinition>>
522
+
523
+ export function createCirclesStoreDefinition(config: CirclesStoreConfig) {
524
+ const {
525
+ storeId = 'circles',
526
+ circlesService,
527
+ getErrorMessage,
528
+ persist,
529
+ } = config
530
+
531
+ return defineStore(storeId, {
532
+ state: (): CirclesState => ({
533
+ circles: [],
534
+ currentCircle: null,
535
+ members: {},
536
+ joinRequests: {},
537
+ joinRequestsPagination: {},
538
+ managementActors: {},
539
+ managementSections: {},
540
+ managementBootstrapLoading: {},
541
+ managementBootstrapError: {},
542
+ managementSectionLoading: {},
543
+ managementSectionErrors: {},
544
+ managementBootstrappedAt: {},
545
+ roleDefinitions: {},
546
+ roleDefinitionsPagination: {},
547
+ managementCounts: {},
548
+ managementPermissionCatalog: {},
549
+ roleAssignments: {},
550
+ roleAssignmentsPagination: {},
551
+ reports: {},
552
+ reportsPagination: {},
553
+ bans: {},
554
+ bansPagination: {},
555
+ mutes: {},
556
+ mutesPagination: {},
557
+ auditLog: {},
558
+ auditLogPagination: {},
559
+ automodRules: {},
560
+ automodRulesPagination: {},
561
+ pendingRequestCircleIds: [],
562
+ loading: false,
563
+ error: null,
564
+ pagination: defaultPaginationState(),
565
+ activeFilterKey: DEFAULT_FILTER_KEY,
566
+ circlesByFilter: {
567
+ [DEFAULT_FILTER_KEY]: []
568
+ },
569
+ paginationByFilter: {
570
+ [DEFAULT_FILTER_KEY]: defaultPaginationState()
571
+ },
572
+ fetchedFilterKeys: {},
573
+ membersPagination: {},
574
+ filters: {}
575
+ }),
576
+
577
+ getters: {
578
+ userCircles: (state) => sanitizeCircleCollection(state.circles).filter(c =>
579
+ c.is_member || c.user_role === 'owner'
580
+ ),
581
+
582
+ joinableCircles: (state) => sanitizeCircleCollection(state.circles).filter(c =>
583
+ c.visibility === 'public' && !c.is_member && c.can_join
584
+ ),
585
+
586
+ managedCircles: (state) => sanitizeCircleCollection(state.circles).filter(c =>
587
+ c.user_role === 'owner' || c.can_manage
588
+ ),
589
+
590
+ filteredCircles: (state) => {
591
+ let filtered = [...sanitizeCircleCollection(state.circles)]
592
+
593
+ if (state.filters.membership === 'joined') {
594
+ filtered = filtered.filter(c => c.is_member || c.user_role === 'owner')
595
+ } else if (state.filters.membership === 'managed') {
596
+ filtered = filtered.filter(c => c.user_role === 'owner' || c.can_manage)
597
+ } else if (state.filters.membership === 'available') {
598
+ filtered = filtered.filter(c => !c.is_member && c.can_join)
599
+ }
600
+
601
+ if (state.filters.visibility) {
602
+ filtered = filtered.filter(c => c.visibility === state.filters.visibility)
603
+ }
604
+
605
+ if (state.filters.search) {
606
+ const searchTerm = state.filters.search.toLowerCase()
607
+ filtered = filtered.filter(c =>
608
+ c.name.toLowerCase().includes(searchTerm) ||
609
+ (c.description && c.description.toLowerCase().includes(searchTerm))
610
+ )
611
+ }
612
+
613
+ return filtered
614
+ },
615
+
616
+ getCircleById: (state) => (id: string) => {
617
+ return sanitizeCircleCollection(state.circles).find(c => c.id === id) || null
618
+ },
619
+
620
+ getCircleMembers: (state) => (circleId: string) => {
621
+ return state.members[circleId] || []
622
+ },
623
+
624
+ getMemberCount: (state) => (circleId: string) => {
625
+ const circle = sanitizeCircleCollection(state.circles).find(c => c.id === circleId)
626
+ return circle?.member_count || 0
627
+ },
628
+
629
+ getManagementActor: (state) => (circleId: string) => {
630
+ return state.managementActors[circleId] ?? null
631
+ },
632
+
633
+ getManagementSections: (state) => (circleId: string) => {
634
+ return state.managementSections[circleId] ?? []
635
+ },
636
+
637
+ getRoleDefinitions: (state) => (circleId: string) => {
638
+ return state.roleDefinitions[circleId] ?? []
639
+ },
640
+
641
+ getManagementCounts: (state) => (circleId: string) => {
642
+ return state.managementCounts[circleId] ?? { ...DEFAULT_MANAGEMENT_COUNTS }
643
+ },
644
+
645
+ getManagementPermissionCatalog: (state) => (circleId: string) => {
646
+ return state.managementPermissionCatalog[circleId] ?? []
647
+ },
648
+
649
+ getRoleAssignments: (state) => (circleId: string) => {
650
+ return state.roleAssignments[circleId] ?? []
651
+ },
652
+
653
+ getModerationReports: (state) => (circleId: string) => {
654
+ return state.reports[circleId] ?? []
655
+ },
656
+
657
+ getBanRecords: (state) => (circleId: string) => {
658
+ return state.bans[circleId] ?? []
659
+ },
660
+
661
+ getMuteRecords: (state) => (circleId: string) => {
662
+ return state.mutes[circleId] ?? []
663
+ },
664
+
665
+ getAuditLogEntries: (state) => (circleId: string) => {
666
+ return state.auditLog[circleId] ?? []
667
+ },
668
+
669
+ getAutomodRules: (state) => (circleId: string) => {
670
+ return state.automodRules[circleId] ?? []
671
+ }
672
+ },
673
+
674
+ actions: {
675
+ setFilters(filters: CircleFilters) {
676
+ const currentKey = this.activeFilterKey
677
+ this.circlesByFilter[currentKey] = [...this.circles]
678
+ this.paginationByFilter[currentKey] = { ...this.pagination }
679
+
680
+ const nextFilters = normalizeFilters(filters)
681
+ const nextKey = buildFilterKey(nextFilters)
682
+
683
+ this.filters = nextFilters
684
+ this.activeFilterKey = nextKey
685
+
686
+ const cachedCircles = this.circlesByFilter[nextKey]
687
+ const cachedPagination = this.paginationByFilter[nextKey]
688
+
689
+ if (cachedCircles) {
690
+ this.circles = sanitizeCircleCollection(cachedCircles)
691
+ } else {
692
+ this.circles = []
693
+ this.circlesByFilter[nextKey] = []
694
+ }
695
+
696
+ if (cachedPagination) {
697
+ this.pagination = { ...cachedPagination }
698
+ } else {
699
+ const defaultPagination = defaultPaginationState()
700
+ this.pagination = defaultPagination
701
+ this.paginationByFilter[nextKey] = { ...defaultPagination }
702
+ }
703
+ },
704
+
705
+ clearFilters() {
706
+ this.setFilters({})
707
+ },
708
+
709
+ hasFetchedCurrentFilter(): boolean {
710
+ return this.fetchedFilterKeys[this.activeFilterKey] === true
711
+ },
712
+
713
+ ensureManagementState(circleId: string) {
714
+ if (!this.managementSectionLoading[circleId]) {
715
+ this.managementSectionLoading[circleId] = {}
716
+ }
717
+
718
+ if (!this.managementSectionErrors[circleId]) {
719
+ this.managementSectionErrors[circleId] = {}
720
+ }
721
+
722
+ if (!this.roleDefinitionsPagination[circleId]) {
723
+ this.roleDefinitionsPagination[circleId] = emptyPaginationState()
724
+ }
725
+
726
+ if (!this.managementCounts[circleId]) {
727
+ this.managementCounts[circleId] = { ...DEFAULT_MANAGEMENT_COUNTS }
728
+ }
729
+
730
+ if (!this.managementPermissionCatalog[circleId]) {
731
+ this.managementPermissionCatalog[circleId] = []
732
+ }
733
+
734
+ if (!this.roleAssignmentsPagination[circleId]) {
735
+ this.roleAssignmentsPagination[circleId] = emptyPaginationState()
736
+ }
737
+
738
+ if (!this.reportsPagination[circleId]) {
739
+ this.reportsPagination[circleId] = emptyPaginationState()
740
+ }
741
+
742
+ if (!this.bansPagination[circleId]) {
743
+ this.bansPagination[circleId] = emptyPaginationState()
744
+ }
745
+
746
+ if (!this.mutesPagination[circleId]) {
747
+ this.mutesPagination[circleId] = emptyPaginationState()
748
+ }
749
+
750
+ if (!this.auditLogPagination[circleId]) {
751
+ this.auditLogPagination[circleId] = emptyPaginationState()
752
+ }
753
+
754
+ if (!this.automodRulesPagination[circleId]) {
755
+ this.automodRulesPagination[circleId] = emptyPaginationState()
756
+ }
757
+
758
+ if (!this.joinRequestsPagination[circleId]) {
759
+ this.joinRequestsPagination[circleId] = emptyPaginationState()
760
+ }
761
+ },
762
+
763
+ syncRoleAssignmentCompatibility(circleId: string) {
764
+ this.ensureManagementState(circleId)
765
+ this.roleAssignments[circleId] = deriveRoleAssignmentsFromMembers(this.members[circleId] ?? [])
766
+ this.roleAssignmentsPagination[circleId] = emptyPaginationState()
767
+ },
768
+
769
+ setRoleDefinitionsState(circleId: string, roles: CircleRoleDefinition[], pagination: CursorPaginationState) {
770
+ this.ensureManagementState(circleId)
771
+ this.roleDefinitions[circleId] = [...roles]
772
+ this.roleDefinitionsPagination[circleId] = { ...pagination }
773
+ this.managementCounts[circleId] = {
774
+ ...DEFAULT_MANAGEMENT_COUNTS,
775
+ ...(this.managementCounts[circleId] ?? {}),
776
+ roles: roles.length,
777
+ }
778
+ },
779
+
780
+ upsertRoleDefinitionState(circleId: string, role: CircleRoleDefinition) {
781
+ this.ensureManagementState(circleId)
782
+ const existingRoles = this.roleDefinitions[circleId] ?? []
783
+ const existingIndex = existingRoles.findIndex((candidate) => candidate.id === role.id)
784
+ const nextRoles = [...existingRoles]
785
+
786
+ if (existingIndex === -1) {
787
+ nextRoles.unshift(role)
788
+ } else {
789
+ nextRoles.splice(existingIndex, 1, role)
790
+ }
791
+
792
+ this.setRoleDefinitionsState(circleId, nextRoles, this.roleDefinitionsPagination[circleId] ?? emptyPaginationState())
793
+ },
794
+
795
+ removeRoleDefinitionState(circleId: string, roleId: string) {
796
+ this.ensureManagementState(circleId)
797
+ const nextRoles = (this.roleDefinitions[circleId] ?? []).filter((role) => role.id !== roleId)
798
+ this.setRoleDefinitionsState(circleId, nextRoles, this.roleDefinitionsPagination[circleId] ?? emptyPaginationState())
799
+ },
800
+
801
+ updateMemberRoleState(circleId: string, userId: string, patch: Partial<CircleMemberState>) {
802
+ const members = this.members[circleId]
803
+ if (!members) {
804
+ return
805
+ }
806
+
807
+ const memberIndex = members.findIndex((member) => member.user_id === userId || member.user?.id === userId)
808
+ if (memberIndex === -1) {
809
+ return
810
+ }
811
+
812
+ const member = members[memberIndex]
813
+ if (!member) {
814
+ return
815
+ }
816
+
817
+ members[memberIndex] = {
818
+ ...member,
819
+ ...patch,
820
+ }
821
+
822
+ this.syncRoleAssignmentCompatibility(circleId)
823
+ },
824
+
825
+ applyMemberRoleAssignments(
826
+ circleId: string,
827
+ userId: string,
828
+ assignedRoles: CircleRoleDefinition[],
829
+ effectivePermissions?: string[],
830
+ ) {
831
+ const members = this.members[circleId]
832
+ if (!members) {
833
+ return
834
+ }
835
+
836
+ const member = members.find((candidate) => candidate.user_id === userId || candidate.user?.id === userId)
837
+ if (!member) {
838
+ return
839
+ }
840
+
841
+ const membershipRole = member.membership_role ?? (member.role === 'owner' ? 'owner' : 'member')
842
+ const displayRole = membershipRole === 'owner'
843
+ ? 'owner'
844
+ : deriveDisplayRole(member, assignedRoles)
845
+
846
+ this.updateMemberRoleState(circleId, userId, {
847
+ membership_role: membershipRole,
848
+ display_role: displayRole,
849
+ role: displayRole,
850
+ assigned_roles: assignedRoles,
851
+ effective_permissions: effectivePermissions ?? buildEffectivePermissions(assignedRoles),
852
+ })
853
+ },
854
+
855
+ setManagementSectionLoading(circleId: string, key: ManagementCollectionKey, loading: boolean) {
856
+ this.ensureManagementState(circleId)
857
+ this.managementSectionLoading[circleId] = {
858
+ ...this.managementSectionLoading[circleId],
859
+ [key]: loading,
860
+ }
861
+ },
862
+
863
+ setManagementSectionError(circleId: string, key: ManagementCollectionKey, error: string | null) {
864
+ this.ensureManagementState(circleId)
865
+ this.managementSectionErrors[circleId] = {
866
+ ...this.managementSectionErrors[circleId],
867
+ [key]: error,
868
+ }
869
+ },
870
+
871
+ applyCircleSnapshot(circle: Circle) {
872
+ const existingIndex = this.circles.findIndex((candidate) => candidate.id === circle.id || candidate.slug === circle.slug)
873
+ if (existingIndex === -1) {
874
+ this.circles.unshift(circle)
875
+ } else {
876
+ this.circles.splice(existingIndex, 1, circle)
877
+ }
878
+
879
+ if (this.currentCircle?.id === circle.id || this.currentCircle?.slug === circle.slug) {
880
+ this.currentCircle = circle
881
+ }
882
+
883
+ this.circlesByFilter[this.activeFilterKey] = [...this.circles]
884
+
885
+ if (circle.actor) {
886
+ this.managementActors[circle.id] = circle.actor
887
+ this.managementSections[circle.id] = [...circle.actor.managementSections]
888
+ }
889
+ },
890
+
891
+ applyManagementBootstrap(bootstrap: CircleManagementBootstrap) {
892
+ const circleId = bootstrap.circle.id
893
+ this.ensureManagementState(circleId)
894
+ this.applyCircleSnapshot(bootstrap.circle)
895
+ this.currentCircle = bootstrap.circle
896
+ this.managementActors[circleId] = bootstrap.actor
897
+ this.managementSections[circleId] = [...bootstrap.management_sections]
898
+ this.managementCounts[circleId] = extractManagementCounts(bootstrap)
899
+ this.managementPermissionCatalog[circleId] = extractManagementPermissionCatalog(bootstrap)
900
+ this.managementBootstrapError[circleId] = null
901
+ this.managementBootstrappedAt[circleId] = new Date().toISOString()
902
+
903
+ this.members[circleId] = [...bootstrap.members.data]
904
+ this.membersPagination[circleId] = toCursorPaginationState(bootstrap.members)
905
+ this.joinRequests[circleId] = [...bootstrap.requests.data]
906
+ this.joinRequestsPagination[circleId] = toCursorPaginationState(bootstrap.requests)
907
+ this.setRoleDefinitionsState(
908
+ circleId,
909
+ extractRoleDefinitions(bootstrap),
910
+ toUnknownCursorPaginationState(isRecord(bootstrap) ? bootstrap.roles : null),
911
+ )
912
+ this.syncRoleAssignmentCompatibility(circleId)
913
+ this.reports[circleId] = [...bootstrap.reports.data]
914
+ this.reportsPagination[circleId] = toCursorPaginationState(bootstrap.reports)
915
+ this.bans[circleId] = [...bootstrap.bans.data]
916
+ this.bansPagination[circleId] = toCursorPaginationState(bootstrap.bans)
917
+ this.mutes[circleId] = [...bootstrap.mutes.data]
918
+ this.mutesPagination[circleId] = toCursorPaginationState(bootstrap.mutes)
919
+ this.auditLog[circleId] = [...bootstrap.audit.data]
920
+ this.auditLogPagination[circleId] = toCursorPaginationState(bootstrap.audit)
921
+ this.automodRules[circleId] = [...bootstrap.automod.data]
922
+ this.automodRulesPagination[circleId] = toCursorPaginationState(bootstrap.automod)
923
+
924
+ this.managementSectionErrors[circleId] = {}
925
+ },
926
+
927
+ async fetchCircles(refresh = false) {
928
+ const filterKey = this.activeFilterKey || buildFilterKey(this.filters)
929
+ this.activeFilterKey = filterKey
930
+
931
+ if (!this.circlesByFilter[filterKey]) {
932
+ this.circlesByFilter[filterKey] = []
933
+ }
934
+
935
+ if (!this.paginationByFilter[filterKey]) {
936
+ this.paginationByFilter[filterKey] = defaultPaginationState()
937
+ }
938
+
939
+ if (refresh) {
940
+ const defaultPagination = defaultPaginationState()
941
+ this.circlesByFilter[filterKey] = []
942
+ this.paginationByFilter[filterKey] = defaultPagination
943
+ this.circles = []
944
+ this.pagination = { ...defaultPagination }
945
+ }
946
+
947
+ const cursorState = this.paginationByFilter[filterKey]
948
+ if (!refresh && !cursorState.hasMore) {
949
+ return
950
+ }
951
+
952
+ this.loading = true
953
+ this.error = null
954
+
955
+ try {
956
+ const response = await circlesService.list(cursorState.cursor, this.filters)
957
+ const sanitizedResponseData = sanitizeCirclesPayload(response.data)
958
+ const nextCircles = refresh
959
+ ? [...sanitizedResponseData]
960
+ : mergeUniqueCircles(sanitizeCircleCollection(this.circlesByFilter[filterKey]), sanitizedResponseData)
961
+ const nextPagination: CursorPaginationState = {
962
+ cursor: response.meta?.pagination?.next_cursor || null,
963
+ hasMore: response.meta?.pagination?.has_more ?? Boolean(response.meta?.pagination?.next_cursor),
964
+ }
965
+
966
+ this.circlesByFilter[filterKey] = sanitizeCircleCollection(nextCircles)
967
+ this.paginationByFilter[filterKey] = nextPagination
968
+ this.fetchedFilterKeys[filterKey] = true
969
+
970
+ if (this.activeFilterKey !== filterKey) {
971
+ return
972
+ }
973
+
974
+ this.circles = sanitizeCircleCollection(nextCircles)
975
+ this.pagination = { ...nextPagination }
976
+ } catch (error: unknown) {
977
+ this.error = getErrorMessage(error)
978
+ } finally {
979
+ this.loading = false
980
+ }
981
+ },
982
+
983
+ async fetchCircle(id: string) {
984
+ this.loading = true
985
+ this.error = null
986
+
987
+ try {
988
+ const circle = await circlesService.get(id)
989
+ this.currentCircle = circle
990
+ this.applyCircleSnapshot(circle)
991
+ return circle
992
+ } catch (error: unknown) {
993
+ this.error = getErrorMessage(error)
994
+ throw error
995
+ } finally {
996
+ this.loading = false
997
+ }
998
+ },
999
+
1000
+ async createCircle(data: CircleCreateInput) {
1001
+ this.loading = true
1002
+ this.error = null
1003
+
1004
+ try {
1005
+ const result = await circlesService.create(data)
1006
+ const newCircle = await circlesService.get(result.id)
1007
+ this.applyCircleSnapshot(newCircle)
1008
+ this.currentCircle = newCircle
1009
+
1010
+ return newCircle
1011
+ } catch (error: unknown) {
1012
+ this.error = getErrorMessage(error)
1013
+ throw error
1014
+ } finally {
1015
+ this.loading = false
1016
+ }
1017
+ },
1018
+
1019
+ async updateCircle(id: string, data: CircleUpdateInput) {
1020
+ this.loading = true
1021
+ this.error = null
1022
+
1023
+ try {
1024
+ const updatedCircle = await circlesService.update(id, data)
1025
+ this.applyCircleSnapshot(updatedCircle)
1026
+ this.currentCircle = updatedCircle
1027
+
1028
+ return updatedCircle
1029
+ } catch (error: unknown) {
1030
+ this.error = getErrorMessage(error)
1031
+ throw error
1032
+ } finally {
1033
+ this.loading = false
1034
+ }
1035
+ },
1036
+
1037
+ async deleteCircle(id: string) {
1038
+ this.loading = true
1039
+ this.error = null
1040
+
1041
+ try {
1042
+ await circlesService.delete(id)
1043
+ this.circles = this.circles.filter(c => c.id !== id)
1044
+
1045
+ if (this.currentCircle?.id === id) {
1046
+ this.currentCircle = null
1047
+ }
1048
+
1049
+ delete this.members[id]
1050
+ delete this.membersPagination[id]
1051
+ delete this.joinRequests[id]
1052
+ delete this.joinRequestsPagination[id]
1053
+ delete this.managementActors[id]
1054
+ delete this.managementSections[id]
1055
+ delete this.managementBootstrapLoading[id]
1056
+ delete this.managementBootstrapError[id]
1057
+ delete this.managementSectionLoading[id]
1058
+ delete this.managementSectionErrors[id]
1059
+ delete this.managementBootstrappedAt[id]
1060
+ delete this.roleDefinitions[id]
1061
+ delete this.roleDefinitionsPagination[id]
1062
+ delete this.managementCounts[id]
1063
+ delete this.managementPermissionCatalog[id]
1064
+ delete this.roleAssignments[id]
1065
+ delete this.roleAssignmentsPagination[id]
1066
+ delete this.reports[id]
1067
+ delete this.reportsPagination[id]
1068
+ delete this.bans[id]
1069
+ delete this.bansPagination[id]
1070
+ delete this.mutes[id]
1071
+ delete this.mutesPagination[id]
1072
+ delete this.auditLog[id]
1073
+ delete this.auditLogPagination[id]
1074
+ delete this.automodRules[id]
1075
+ delete this.automodRulesPagination[id]
1076
+ } catch (error: unknown) {
1077
+ this.error = getErrorMessage(error)
1078
+ throw error
1079
+ } finally {
1080
+ this.loading = false
1081
+ }
1082
+ },
1083
+
1084
+ async joinCircle(id: string) {
1085
+ this.error = null
1086
+
1087
+ try {
1088
+ await circlesService.join(id)
1089
+
1090
+ const circleIndex = this.circles.findIndex(c => c.id === id)
1091
+ if (circleIndex !== -1) {
1092
+ const circle = this.circles[circleIndex]
1093
+ if (!circle) {
1094
+ return
1095
+ }
1096
+
1097
+ this.circles.splice(circleIndex, 1, {
1098
+ ...circle,
1099
+ is_member: true,
1100
+ user_role: 'member',
1101
+ member_count: (circle.member_count || 0) + 1,
1102
+ can_join: false,
1103
+ can_leave: true
1104
+ })
1105
+ }
1106
+
1107
+ if (this.currentCircle?.id === id) {
1108
+ this.currentCircle = {
1109
+ ...this.currentCircle,
1110
+ is_member: true,
1111
+ user_role: 'member',
1112
+ member_count: (this.currentCircle.member_count || 0) + 1,
1113
+ can_join: false,
1114
+ can_leave: true
1115
+ }
1116
+ }
1117
+ } catch (error: unknown) {
1118
+ this.error = getErrorMessage(error)
1119
+ throw error
1120
+ }
1121
+ },
1122
+
1123
+ async leaveCircle(id: string) {
1124
+ this.error = null
1125
+
1126
+ try {
1127
+ await circlesService.leave(id)
1128
+
1129
+ const circleIndex = this.circles.findIndex(c => c.id === id)
1130
+ if (circleIndex !== -1) {
1131
+ const circle = this.circles[circleIndex]
1132
+ if (!circle) {
1133
+ return
1134
+ }
1135
+
1136
+ this.circles.splice(circleIndex, 1, {
1137
+ ...circle,
1138
+ is_member: false,
1139
+ user_role: null,
1140
+ member_count: Math.max((circle.member_count || 0) - 1, 0),
1141
+ can_join: circle.visibility === 'public',
1142
+ can_leave: false,
1143
+ can_request_to_join: circle.visibility !== 'public',
1144
+ has_pending_request: false
1145
+ })
1146
+ }
1147
+
1148
+ this.pendingRequestCircleIds = this.pendingRequestCircleIds.filter(cid => cid !== id)
1149
+
1150
+ if (this.currentCircle?.id === id) {
1151
+ this.currentCircle = {
1152
+ ...this.currentCircle,
1153
+ is_member: false,
1154
+ user_role: null,
1155
+ member_count: Math.max((this.currentCircle.member_count || 0) - 1, 0),
1156
+ can_join: this.currentCircle.visibility === 'public',
1157
+ can_leave: false,
1158
+ can_request_to_join: this.currentCircle.visibility !== 'public',
1159
+ has_pending_request: false
1160
+ }
1161
+ }
1162
+ } catch (error: unknown) {
1163
+ this.error = getErrorMessage(error)
1164
+ throw error
1165
+ }
1166
+ },
1167
+
1168
+ applyLiveMemberCount(circleId: string, memberCount: number) {
1169
+ const nextCount = Math.max(memberCount, 0)
1170
+
1171
+ const circleIndex = this.circles.findIndex(c => c.id === circleId)
1172
+ if (circleIndex !== -1) {
1173
+ const circle = this.circles[circleIndex]
1174
+ if (circle) {
1175
+ this.circles.splice(circleIndex, 1, {
1176
+ ...circle,
1177
+ member_count: nextCount
1178
+ })
1179
+ }
1180
+ }
1181
+
1182
+ if (this.currentCircle?.id === circleId) {
1183
+ this.currentCircle = {
1184
+ ...this.currentCircle,
1185
+ member_count: nextCount
1186
+ }
1187
+ }
1188
+ },
1189
+
1190
+ async fetchMembers(circleId: string, refresh = false) {
1191
+ if (refresh) {
1192
+ this.members[circleId] = []
1193
+ this.membersPagination[circleId] = {
1194
+ cursor: null,
1195
+ hasMore: true
1196
+ }
1197
+ }
1198
+
1199
+ this.loading = true
1200
+ this.error = null
1201
+
1202
+ try {
1203
+ const pagination = this.membersPagination[circleId] || { cursor: null, hasMore: true }
1204
+ const response = await circlesService.getMembers(circleId, pagination.cursor)
1205
+
1206
+ if (refresh) {
1207
+ this.members[circleId] = response.data
1208
+ } else {
1209
+ this.members[circleId] = [
1210
+ ...(this.members[circleId] || []),
1211
+ ...response.data
1212
+ ]
1213
+ }
1214
+
1215
+ this.membersPagination[circleId] = toCursorPaginationState(response)
1216
+ this.syncRoleAssignmentCompatibility(circleId)
1217
+ } catch (error: unknown) {
1218
+ this.error = getErrorMessage(error)
1219
+ throw error
1220
+ } finally {
1221
+ this.loading = false
1222
+ }
1223
+ },
1224
+
1225
+ addMember(circleId: string, member: CircleMemberState) {
1226
+ if (!this.members[circleId]) {
1227
+ this.members[circleId] = []
1228
+ }
1229
+
1230
+ if (!this.members[circleId].find(m => m.id === member.id)) {
1231
+ this.members[circleId].push(member)
1232
+ this.syncRoleAssignmentCompatibility(circleId)
1233
+
1234
+ const circleIndex = this.circles.findIndex(c => c.id === circleId)
1235
+ if (circleIndex !== -1) {
1236
+ const circle = this.circles[circleIndex]
1237
+ if (circle) {
1238
+ circle.member_count = (circle.member_count || 0) + 1
1239
+ }
1240
+ }
1241
+
1242
+ if (this.currentCircle?.id === circleId) {
1243
+ this.currentCircle.member_count = (this.currentCircle.member_count || 0) + 1
1244
+ }
1245
+ }
1246
+ },
1247
+
1248
+ removeMember(circleId: string, userId: string) {
1249
+ if (this.members[circleId]) {
1250
+ const nextMembers = this.members[circleId].filter(
1251
+ m => m.user_id !== userId && m.user?.id !== userId
1252
+ )
1253
+ this.members[circleId] = nextMembers
1254
+ this.syncRoleAssignmentCompatibility(circleId)
1255
+ const nextCount = nextMembers.length
1256
+
1257
+ const circleIndex = this.circles.findIndex(c => c.id === circleId)
1258
+ if (circleIndex !== -1) {
1259
+ const circle = this.circles[circleIndex]
1260
+ if (circle) {
1261
+ circle.member_count = nextCount
1262
+ }
1263
+ }
1264
+
1265
+ if (this.currentCircle?.id === circleId) {
1266
+ this.currentCircle.member_count = nextCount
1267
+ }
1268
+
1269
+ return
1270
+ }
1271
+
1272
+ const circleIndex = this.circles.findIndex(c => c.id === circleId)
1273
+ if (circleIndex !== -1) {
1274
+ const circle = this.circles[circleIndex]
1275
+ if (circle) {
1276
+ circle.member_count = Math.max((circle.member_count || 0) - 1, 0)
1277
+ }
1278
+ }
1279
+
1280
+ if (this.currentCircle?.id === circleId) {
1281
+ this.currentCircle.member_count = Math.max((this.currentCircle.member_count || 0) - 1, 0)
1282
+ }
1283
+ },
1284
+
1285
+ async changeMemberRole(circleId: string, userId: string, newRole: 'admin' | 'member') {
1286
+ this.error = null
1287
+
1288
+ try {
1289
+ await circlesService.changeMemberRole(circleId, userId, newRole)
1290
+
1291
+ const assignedRoles = newRole === 'admin'
1292
+ ? (this.roleDefinitions[circleId] ?? []).filter((role) => role.slug === 'group-admin')
1293
+ : []
1294
+
1295
+ if (newRole === 'admin' && assignedRoles.length === 0) {
1296
+ this.updateMemberRoleState(circleId, userId, {
1297
+ membership_role: 'member',
1298
+ display_role: 'admin',
1299
+ role: 'admin',
1300
+ assigned_roles: [],
1301
+ effective_permissions: [],
1302
+ })
1303
+ return
1304
+ }
1305
+
1306
+ if (newRole === 'member') {
1307
+ this.updateMemberRoleState(circleId, userId, {
1308
+ membership_role: 'member',
1309
+ display_role: 'member',
1310
+ role: 'member',
1311
+ assigned_roles: [],
1312
+ effective_permissions: [],
1313
+ })
1314
+ return
1315
+ }
1316
+
1317
+ this.applyMemberRoleAssignments(
1318
+ circleId,
1319
+ userId,
1320
+ assignedRoles,
1321
+ buildEffectivePermissions(assignedRoles),
1322
+ )
1323
+ } catch (error: unknown) {
1324
+ this.error = getErrorMessage(error)
1325
+ throw error
1326
+ }
1327
+ },
1328
+
1329
+ async removeMemberFromCircle(circleId: string, userId: string) {
1330
+ this.error = null
1331
+
1332
+ try {
1333
+ await circlesService.removeMember(circleId, userId)
1334
+ this.removeMember(circleId, userId)
1335
+ } catch (error: unknown) {
1336
+ this.error = getErrorMessage(error)
1337
+ throw error
1338
+ }
1339
+ },
1340
+
1341
+ async muteMemberInCircle(circleId: string, userId: string, options?: CircleMuteMemberOptions) {
1342
+ this.error = null
1343
+
1344
+ try {
1345
+ await circlesService.muteMember(circleId, userId, options)
1346
+ await Promise.allSettled([
1347
+ this.fetchMutes(circleId, true),
1348
+ this.fetchAuditLog(circleId, true),
1349
+ ])
1350
+ } catch (error: unknown) {
1351
+ this.error = getErrorMessage(error)
1352
+ throw error
1353
+ }
1354
+ },
1355
+
1356
+ async bootstrapManagement(circleId: string) {
1357
+ this.ensureManagementState(circleId)
1358
+ this.managementBootstrapLoading[circleId] = true
1359
+ this.managementBootstrapError[circleId] = null
1360
+
1361
+ try {
1362
+ const bootstrap = await circlesService.getManagementBootstrap(circleId)
1363
+ this.applyManagementBootstrap(bootstrap)
1364
+ return bootstrap
1365
+ } catch (error: unknown) {
1366
+ const message = getErrorMessage(error)
1367
+ this.managementBootstrapError[circleId] = message
1368
+ this.error = message
1369
+ throw error
1370
+ } finally {
1371
+ this.managementBootstrapLoading[circleId] = false
1372
+ }
1373
+ },
1374
+
1375
+ async fetchRoleDefinitions(circleId: string, refresh = false) {
1376
+ this.setManagementSectionLoading(circleId, 'roles', true)
1377
+ this.setManagementSectionError(circleId, 'roles', null)
1378
+
1379
+ try {
1380
+ const roleService = getRoleStoreService(circlesService)
1381
+ const cursor = refresh ? null : this.roleDefinitionsPagination[circleId]?.cursor ?? null
1382
+
1383
+ let response: unknown
1384
+ let usedLegacyListRolesFallback = false
1385
+
1386
+ if (typeof roleService.fetchRoleDefinitions === 'function') {
1387
+ response = await roleService.fetchRoleDefinitions(circleId, cursor)
1388
+ } else if (typeof roleService.listRoleDefinitions === 'function') {
1389
+ response = await roleService.listRoleDefinitions(circleId, cursor)
1390
+ } else if (typeof roleService.listRoles === 'function') {
1391
+ usedLegacyListRolesFallback = true
1392
+ response = await roleService.listRoles(circleId, cursor)
1393
+ } else {
1394
+ response = []
1395
+ }
1396
+
1397
+ const incomingRoles = extractRoleDefinitions(response)
1398
+ if (usedLegacyListRolesFallback && incomingRoles.length === 0) {
1399
+ return this.roleDefinitions[circleId] ?? []
1400
+ }
1401
+
1402
+ const nextRoles = refresh
1403
+ ? [...incomingRoles]
1404
+ : [
1405
+ ...(this.roleDefinitions[circleId] ?? []),
1406
+ ...incomingRoles.filter((role) => !(this.roleDefinitions[circleId] ?? []).some((existing) => existing.id === role.id)),
1407
+ ]
1408
+
1409
+ this.setRoleDefinitionsState(circleId, nextRoles, toUnknownCursorPaginationState(response))
1410
+ return nextRoles
1411
+ } catch (error: unknown) {
1412
+ const message = getErrorMessage(error)
1413
+ this.setManagementSectionError(circleId, 'roles', message)
1414
+ throw error
1415
+ } finally {
1416
+ this.setManagementSectionLoading(circleId, 'roles', false)
1417
+ }
1418
+ },
1419
+
1420
+ async createRoleDefinition(circleId: string, input: CircleRoleDefinitionInput) {
1421
+ this.error = null
1422
+
1423
+ try {
1424
+ const roleService = getRoleStoreService(circlesService)
1425
+ let response: unknown
1426
+
1427
+ if (typeof roleService.createRoleDefinition === 'function') {
1428
+ response = await roleService.createRoleDefinition(circleId, input)
1429
+ } else if (typeof roleService.createRole === 'function') {
1430
+ response = await roleService.createRole(circleId, input)
1431
+ } else {
1432
+ throw new Error('Role definition creation is unavailable.')
1433
+ }
1434
+
1435
+ const role = normalizeRoleDefinition(response)
1436
+ if (role === null) {
1437
+ throw new Error('Invalid role definition payload')
1438
+ }
1439
+
1440
+ this.upsertRoleDefinitionState(circleId, role)
1441
+ await this.fetchRoleDefinitions(circleId, true)
1442
+ return role
1443
+ } catch (error: unknown) {
1444
+ this.error = getErrorMessage(error)
1445
+ throw error
1446
+ }
1447
+ },
1448
+
1449
+ async updateRoleDefinition(circleId: string, roleId: string, input: CircleRoleDefinitionUpdateInput) {
1450
+ this.error = null
1451
+
1452
+ try {
1453
+ const roleService = getRoleStoreService(circlesService)
1454
+ let response: unknown
1455
+
1456
+ if (typeof roleService.updateRoleDefinition === 'function') {
1457
+ response = await roleService.updateRoleDefinition(circleId, roleId, input)
1458
+ } else if (typeof roleService.updateRole === 'function') {
1459
+ response = await roleService.updateRole(circleId, roleId, input)
1460
+ } else {
1461
+ throw new Error('Role definition updates are unavailable.')
1462
+ }
1463
+
1464
+ const role = normalizeRoleDefinition(response)
1465
+ if (role === null) {
1466
+ throw new Error('Invalid role definition payload')
1467
+ }
1468
+
1469
+ this.upsertRoleDefinitionState(circleId, role)
1470
+ await this.fetchRoleDefinitions(circleId, true)
1471
+ return role
1472
+ } catch (error: unknown) {
1473
+ this.error = getErrorMessage(error)
1474
+ throw error
1475
+ }
1476
+ },
1477
+
1478
+ async replaceRoleDefinitionPermissions(circleId: string, roleId: string, permissions: string[]) {
1479
+ this.error = null
1480
+
1481
+ try {
1482
+ const roleService = getRoleStoreService(circlesService)
1483
+ const sanitizedPermissions = sanitizePermissionKeys(permissions)
1484
+ let response: unknown
1485
+
1486
+ if (typeof roleService.replaceRoleDefinitionPermissions === 'function') {
1487
+ response = await roleService.replaceRoleDefinitionPermissions(circleId, roleId, sanitizedPermissions)
1488
+ } else if (typeof roleService.replaceRolePermissions === 'function') {
1489
+ response = await roleService.replaceRolePermissions(circleId, roleId, sanitizedPermissions)
1490
+ } else {
1491
+ throw new Error('Role permission updates are unavailable.')
1492
+ }
1493
+
1494
+ const role = normalizeRoleDefinition(response)
1495
+ if (role === null) {
1496
+ throw new Error('Invalid role definition payload')
1497
+ }
1498
+
1499
+ this.upsertRoleDefinitionState(circleId, role)
1500
+ await this.fetchRoleDefinitions(circleId, true)
1501
+ return role
1502
+ } catch (error: unknown) {
1503
+ this.error = getErrorMessage(error)
1504
+ throw error
1505
+ }
1506
+ },
1507
+
1508
+ async archiveRoleDefinition(circleId: string, roleId: string) {
1509
+ this.error = null
1510
+
1511
+ try {
1512
+ const roleService = getRoleStoreService(circlesService)
1513
+
1514
+ if (typeof roleService.archiveRoleDefinition === 'function') {
1515
+ await roleService.archiveRoleDefinition(circleId, roleId)
1516
+ } else if (typeof roleService.archiveRole === 'function') {
1517
+ await roleService.archiveRole(circleId, roleId)
1518
+ } else {
1519
+ throw new Error('Role archiving is unavailable.')
1520
+ }
1521
+
1522
+ this.removeRoleDefinitionState(circleId, roleId)
1523
+ } catch (error: unknown) {
1524
+ this.error = getErrorMessage(error)
1525
+ throw error
1526
+ }
1527
+ },
1528
+
1529
+ async assignMemberRoles(circleId: string, userId: string, roleIds: string[]) {
1530
+ this.error = null
1531
+
1532
+ try {
1533
+ const roleService = getRoleStoreService(circlesService)
1534
+ const normalizedRoleIds = [...new Set(roleIds.filter((roleId) => roleId.trim().length > 0))]
1535
+
1536
+ if (typeof roleService.assignMemberRoles === 'function') {
1537
+ const response = await roleService.assignMemberRoles(circleId, userId, normalizedRoleIds)
1538
+ const responseRecord = isRecord(response) ? response : {}
1539
+ const assignedRoles = sanitizeRoleDefinitions(responseRecord.assigned_roles)
1540
+ const effectivePermissions = sanitizePermissionKeys(responseRecord.effective_permissions)
1541
+
1542
+ this.applyMemberRoleAssignments(circleId, userId, assignedRoles, effectivePermissions)
1543
+ return {
1544
+ assigned_roles: assignedRoles,
1545
+ effective_permissions: effectivePermissions,
1546
+ } satisfies CircleMemberRoleAssignmentResult
1547
+ }
1548
+
1549
+ const selectedRoles = (this.roleDefinitions[circleId] ?? []).filter((role) => normalizedRoleIds.includes(role.id))
1550
+ if (selectedRoles.length === 1 && selectedRoles[0]?.slug === 'group-admin') {
1551
+ await this.changeMemberRole(circleId, userId, 'admin')
1552
+ return {
1553
+ assigned_roles: selectedRoles,
1554
+ effective_permissions: buildEffectivePermissions(selectedRoles),
1555
+ } satisfies CircleMemberRoleAssignmentResult
1556
+ }
1557
+
1558
+ throw new Error('Member role assignment is unavailable.')
1559
+ } catch (error: unknown) {
1560
+ this.error = getErrorMessage(error)
1561
+ throw error
1562
+ }
1563
+ },
1564
+
1565
+ async removeMemberRole(circleId: string, userId: string, roleId: string) {
1566
+ this.error = null
1567
+
1568
+ try {
1569
+ const roleService = getRoleStoreService(circlesService)
1570
+ if (typeof roleService.removeMemberRole === 'function') {
1571
+ await roleService.removeMemberRole(circleId, userId, roleId)
1572
+ } else {
1573
+ const member = (this.members[circleId] ?? []).find((candidate) => candidate.user_id === userId || candidate.user?.id === userId)
1574
+ const assignedRoles = member ? extractAssignedRoles(member) : []
1575
+ const nextAssignedRoles = assignedRoles.filter((role) => role.id !== roleId)
1576
+ const removedRole = assignedRoles.find((role) => role.id === roleId)
1577
+
1578
+ if (removedRole?.slug === 'group-admin' && nextAssignedRoles.length === 0) {
1579
+ await this.changeMemberRole(circleId, userId, 'member')
1580
+ return
1581
+ }
1582
+
1583
+ throw new Error('Member role removal is unavailable.')
1584
+ }
1585
+
1586
+ const member = (this.members[circleId] ?? []).find((candidate) => candidate.user_id === userId || candidate.user?.id === userId)
1587
+ const nextAssignedRoles = member
1588
+ ? extractAssignedRoles(member).filter((role) => role.id !== roleId)
1589
+ : []
1590
+
1591
+ if (nextAssignedRoles.length === 0) {
1592
+ this.updateMemberRoleState(circleId, userId, {
1593
+ membership_role: 'member',
1594
+ display_role: 'member',
1595
+ role: 'member',
1596
+ assigned_roles: [],
1597
+ effective_permissions: [],
1598
+ })
1599
+ return
1600
+ }
1601
+
1602
+ this.applyMemberRoleAssignments(circleId, userId, nextAssignedRoles, buildEffectivePermissions(nextAssignedRoles))
1603
+ } catch (error: unknown) {
1604
+ this.error = getErrorMessage(error)
1605
+ throw error
1606
+ }
1607
+ },
1608
+
1609
+ async fetchRoleAssignments(circleId: string, refresh = false) {
1610
+ this.setManagementSectionLoading(circleId, 'roles', true)
1611
+ this.setManagementSectionError(circleId, 'roles', null)
1612
+
1613
+ try {
1614
+ if (refresh || !this.members[circleId]) {
1615
+ await this.fetchMembers(circleId, refresh)
1616
+ }
1617
+
1618
+ this.syncRoleAssignmentCompatibility(circleId)
1619
+ return this.roleAssignments[circleId] ?? []
1620
+ } catch (error: unknown) {
1621
+ const message = getErrorMessage(error)
1622
+ this.setManagementSectionError(circleId, 'roles', message)
1623
+ throw error
1624
+ } finally {
1625
+ this.setManagementSectionLoading(circleId, 'roles', false)
1626
+ }
1627
+ },
1628
+
1629
+ async transferOwnership(circleId: string, userId: string) {
1630
+ this.error = null
1631
+
1632
+ try {
1633
+ await circlesService.transferOwnership(circleId, userId)
1634
+ await this.bootstrapManagement(circleId)
1635
+ } catch (error: unknown) {
1636
+ this.error = getErrorMessage(error)
1637
+ throw error
1638
+ }
1639
+ },
1640
+
1641
+ async fetchModerationReports(circleId: string, refresh = false, status: 'pending' | 'actioned' | 'dismissed' = 'pending') {
1642
+ this.setManagementSectionLoading(circleId, 'reports', true)
1643
+ this.setManagementSectionError(circleId, 'reports', null)
1644
+
1645
+ try {
1646
+ const cursor = refresh ? null : this.reportsPagination[circleId]?.cursor ?? null
1647
+ const response = await circlesService.listModerationReports(circleId, cursor, status)
1648
+ const nextReports = refresh
1649
+ ? [...response.data]
1650
+ : [
1651
+ ...(this.reports[circleId] ?? []),
1652
+ ...response.data.filter((report) => !(this.reports[circleId] ?? []).some((existing) => existing.id === report.id)),
1653
+ ]
1654
+
1655
+ this.reports[circleId] = nextReports
1656
+ this.reportsPagination[circleId] = toCursorPaginationState(response)
1657
+ return nextReports
1658
+ } catch (error: unknown) {
1659
+ const message = getErrorMessage(error)
1660
+ this.setManagementSectionError(circleId, 'reports', message)
1661
+ throw error
1662
+ } finally {
1663
+ this.setManagementSectionLoading(circleId, 'reports', false)
1664
+ }
1665
+ },
1666
+
1667
+ async reportMemberInCircle(circleId: string, subjectUserId: string, category: string, notes?: string) {
1668
+ this.error = null
1669
+
1670
+ try {
1671
+ await circlesService.reportMember(circleId, subjectUserId, category, notes)
1672
+ await Promise.allSettled([
1673
+ this.fetchModerationReports(circleId, true),
1674
+ this.fetchAuditLog(circleId, true),
1675
+ ])
1676
+ } catch (error: unknown) {
1677
+ this.error = getErrorMessage(error)
1678
+ throw error
1679
+ }
1680
+ },
1681
+
1682
+ async resolveReportInCircle(circleId: string, reportId: string, decision: CircleReportDecision, resolutionNotes?: string) {
1683
+ this.error = null
1684
+
1685
+ try {
1686
+ const result = await circlesService.resolveModerationReport(circleId, reportId, decision, resolutionNotes)
1687
+ if (this.reports[circleId]) {
1688
+ this.reports[circleId] = this.reports[circleId].map((report) => (
1689
+ report.id === reportId
1690
+ ? {
1691
+ ...report,
1692
+ status: result.status === 'actioned' || result.status === 'dismissed'
1693
+ ? result.status
1694
+ : report.status,
1695
+ resolution_notes: resolutionNotes ?? report.resolution_notes ?? null,
1696
+ }
1697
+ : report
1698
+ ))
1699
+ }
1700
+
1701
+ await this.fetchAuditLog(circleId, true)
1702
+ return result
1703
+ } catch (error: unknown) {
1704
+ this.error = getErrorMessage(error)
1705
+ throw error
1706
+ }
1707
+ },
1708
+
1709
+ async fetchBans(circleId: string, refresh = false) {
1710
+ this.setManagementSectionLoading(circleId, 'bans', true)
1711
+ this.setManagementSectionError(circleId, 'bans', null)
1712
+
1713
+ try {
1714
+ const cursor = refresh ? null : this.bansPagination[circleId]?.cursor ?? null
1715
+ const response = await circlesService.listBans(circleId, cursor)
1716
+ const nextBans = refresh
1717
+ ? [...response.data]
1718
+ : [
1719
+ ...(this.bans[circleId] ?? []),
1720
+ ...response.data.filter((ban) => !(this.bans[circleId] ?? []).some((existing) => existing.id === ban.id)),
1721
+ ]
1722
+
1723
+ this.bans[circleId] = nextBans
1724
+ this.bansPagination[circleId] = toCursorPaginationState(response)
1725
+ return nextBans
1726
+ } catch (error: unknown) {
1727
+ const message = getErrorMessage(error)
1728
+ this.setManagementSectionError(circleId, 'bans', message)
1729
+ throw error
1730
+ } finally {
1731
+ this.setManagementSectionLoading(circleId, 'bans', false)
1732
+ }
1733
+ },
1734
+
1735
+ async banMemberInCircle(circleId: string, userId: string, options?: { reason?: string }) {
1736
+ this.error = null
1737
+
1738
+ try {
1739
+ await circlesService.banMember(circleId, userId, options)
1740
+ this.removeMember(circleId, userId)
1741
+ await Promise.allSettled([
1742
+ this.fetchBans(circleId, true),
1743
+ this.fetchAuditLog(circleId, true),
1744
+ this.fetchRoleAssignments(circleId, true),
1745
+ ])
1746
+ } catch (error: unknown) {
1747
+ this.error = getErrorMessage(error)
1748
+ throw error
1749
+ }
1750
+ },
1751
+
1752
+ async unbanMemberInCircle(circleId: string, userId: string) {
1753
+ this.error = null
1754
+
1755
+ try {
1756
+ await circlesService.unbanMember(circleId, userId)
1757
+ await Promise.allSettled([
1758
+ this.fetchBans(circleId, true),
1759
+ this.fetchAuditLog(circleId, true),
1760
+ ])
1761
+ } catch (error: unknown) {
1762
+ this.error = getErrorMessage(error)
1763
+ throw error
1764
+ }
1765
+ },
1766
+
1767
+ async fetchMutes(circleId: string, refresh = false) {
1768
+ this.setManagementSectionLoading(circleId, 'mutes', true)
1769
+ this.setManagementSectionError(circleId, 'mutes', null)
1770
+
1771
+ try {
1772
+ const cursor = refresh ? null : this.mutesPagination[circleId]?.cursor ?? null
1773
+ const response = await circlesService.listMutes(circleId, cursor)
1774
+ const nextMutes = refresh
1775
+ ? [...response.data]
1776
+ : [
1777
+ ...(this.mutes[circleId] ?? []),
1778
+ ...response.data.filter((mute) => !(this.mutes[circleId] ?? []).some((existing) => existing.id === mute.id)),
1779
+ ]
1780
+
1781
+ this.mutes[circleId] = nextMutes
1782
+ this.mutesPagination[circleId] = toCursorPaginationState(response)
1783
+ return nextMutes
1784
+ } catch (error: unknown) {
1785
+ const message = getErrorMessage(error)
1786
+ this.setManagementSectionError(circleId, 'mutes', message)
1787
+ throw error
1788
+ } finally {
1789
+ this.setManagementSectionLoading(circleId, 'mutes', false)
1790
+ }
1791
+ },
1792
+
1793
+ async unmuteMemberInCircle(circleId: string, userId: string) {
1794
+ this.error = null
1795
+
1796
+ try {
1797
+ await circlesService.unmuteMember(circleId, userId)
1798
+ await Promise.allSettled([
1799
+ this.fetchMutes(circleId, true),
1800
+ this.fetchAuditLog(circleId, true),
1801
+ ])
1802
+ } catch (error: unknown) {
1803
+ this.error = getErrorMessage(error)
1804
+ throw error
1805
+ }
1806
+ },
1807
+
1808
+ async fetchAuditLog(circleId: string, refresh = false) {
1809
+ this.setManagementSectionLoading(circleId, 'audit', true)
1810
+ this.setManagementSectionError(circleId, 'audit', null)
1811
+
1812
+ try {
1813
+ const cursor = refresh ? null : this.auditLogPagination[circleId]?.cursor ?? null
1814
+ const response = await circlesService.getModerationAuditLog(circleId, cursor)
1815
+ const nextEntries = refresh
1816
+ ? [...response.data]
1817
+ : [
1818
+ ...(this.auditLog[circleId] ?? []),
1819
+ ...response.data.filter((entry) => !(this.auditLog[circleId] ?? []).some((existing) => existing.id === entry.id)),
1820
+ ]
1821
+
1822
+ this.auditLog[circleId] = nextEntries
1823
+ this.auditLogPagination[circleId] = toCursorPaginationState(response)
1824
+ return nextEntries
1825
+ } catch (error: unknown) {
1826
+ const message = getErrorMessage(error)
1827
+ this.setManagementSectionError(circleId, 'audit', message)
1828
+ throw error
1829
+ } finally {
1830
+ this.setManagementSectionLoading(circleId, 'audit', false)
1831
+ }
1832
+ },
1833
+
1834
+ async fetchAutomodRules(circleId: string, refresh = false) {
1835
+ this.setManagementSectionLoading(circleId, 'automod', true)
1836
+ this.setManagementSectionError(circleId, 'automod', null)
1837
+
1838
+ try {
1839
+ const cursor = refresh ? null : this.automodRulesPagination[circleId]?.cursor ?? null
1840
+ const response = await circlesService.listAutomodRules(circleId, cursor)
1841
+ const nextRules = refresh
1842
+ ? [...response.data]
1843
+ : [
1844
+ ...(this.automodRules[circleId] ?? []),
1845
+ ...response.data.filter((rule) => !(this.automodRules[circleId] ?? []).some((existing) => existing.id === rule.id)),
1846
+ ]
1847
+
1848
+ this.automodRules[circleId] = nextRules
1849
+ this.automodRulesPagination[circleId] = toCursorPaginationState(response)
1850
+ return nextRules
1851
+ } catch (error: unknown) {
1852
+ const message = getErrorMessage(error)
1853
+ this.setManagementSectionError(circleId, 'automod', message)
1854
+ throw error
1855
+ } finally {
1856
+ this.setManagementSectionLoading(circleId, 'automod', false)
1857
+ }
1858
+ },
1859
+
1860
+ async createAutomodRuleInCircle(circleId: string, input: CircleAutomodRuleInput) {
1861
+ this.error = null
1862
+
1863
+ try {
1864
+ const rule = await circlesService.createAutomodRule(circleId, input)
1865
+ this.automodRules[circleId] = [rule, ...(this.automodRules[circleId] ?? [])]
1866
+ await this.fetchAuditLog(circleId, true)
1867
+ return rule
1868
+ } catch (error: unknown) {
1869
+ this.error = getErrorMessage(error)
1870
+ throw error
1871
+ }
1872
+ },
1873
+
1874
+ async updateAutomodRuleInCircle(circleId: string, ruleId: string, input: Partial<CircleAutomodRuleInput>) {
1875
+ this.error = null
1876
+
1877
+ try {
1878
+ const updatedRule = await circlesService.updateAutomodRule(circleId, ruleId, input)
1879
+ this.automodRules[circleId] = (this.automodRules[circleId] ?? []).map((rule) => (
1880
+ rule.id === updatedRule.id ? updatedRule : rule
1881
+ ))
1882
+ await this.fetchAuditLog(circleId, true)
1883
+ return updatedRule
1884
+ } catch (error: unknown) {
1885
+ this.error = getErrorMessage(error)
1886
+ throw error
1887
+ }
1888
+ },
1889
+
1890
+ async deleteAutomodRuleInCircle(circleId: string, ruleId: string) {
1891
+ this.error = null
1892
+
1893
+ try {
1894
+ await circlesService.deleteAutomodRule(circleId, ruleId)
1895
+ this.automodRules[circleId] = (this.automodRules[circleId] ?? []).filter((rule) => rule.id !== ruleId)
1896
+ await this.fetchAuditLog(circleId, true)
1897
+ } catch (error: unknown) {
1898
+ this.error = getErrorMessage(error)
1899
+ throw error
1900
+ }
1901
+ },
1902
+
1903
+ async requestJoinCircle(id: string, message?: string) {
1904
+ this.error = null
1905
+
1906
+ try {
1907
+ await circlesService.requestToJoin(id, message)
1908
+
1909
+ if (!this.pendingRequestCircleIds.includes(id)) {
1910
+ this.pendingRequestCircleIds.push(id)
1911
+ }
1912
+
1913
+ const circleIndex = this.circles.findIndex(c => c.id === id)
1914
+ if (circleIndex !== -1) {
1915
+ const circle = this.circles[circleIndex]
1916
+ if (!circle) {
1917
+ return
1918
+ }
1919
+
1920
+ this.circles.splice(circleIndex, 1, {
1921
+ ...circle,
1922
+ has_pending_request: true,
1923
+ can_request_to_join: false
1924
+ })
1925
+ }
1926
+
1927
+ if (this.currentCircle?.id === id) {
1928
+ this.currentCircle = {
1929
+ ...this.currentCircle,
1930
+ has_pending_request: true,
1931
+ can_request_to_join: false
1932
+ }
1933
+ }
1934
+ } catch (error: unknown) {
1935
+ this.error = getErrorMessage(error)
1936
+ throw error
1937
+ }
1938
+ },
1939
+
1940
+ async fetchJoinRequests(circleId: string, status?: string) {
1941
+ this.error = null
1942
+ this.setManagementSectionLoading(circleId, 'requests', true)
1943
+ this.setManagementSectionError(circleId, 'requests', null)
1944
+
1945
+ try {
1946
+ const response = await circlesService.listJoinRequests(circleId, null, status)
1947
+ this.joinRequests[circleId] = response.data
1948
+ this.joinRequestsPagination[circleId] = toCursorPaginationState(response)
1949
+ } catch (error: unknown) {
1950
+ const message = getErrorMessage(error)
1951
+ this.error = message
1952
+ this.setManagementSectionError(circleId, 'requests', message)
1953
+ throw error
1954
+ } finally {
1955
+ this.setManagementSectionLoading(circleId, 'requests', false)
1956
+ }
1957
+ },
1958
+
1959
+ async moderateJoinRequest(circleId: string, requestId: string, decision: 'approve' | 'reject') {
1960
+ this.error = null
1961
+
1962
+ try {
1963
+ await circlesService.moderateJoinRequest(circleId, requestId, decision)
1964
+
1965
+ if (this.joinRequests[circleId]) {
1966
+ this.joinRequests[circleId] = this.joinRequests[circleId].filter(r => r.id !== requestId)
1967
+ }
1968
+
1969
+ if (decision === 'approve') {
1970
+ const circleIndex = this.circles.findIndex(c => c.id === circleId)
1971
+ if (circleIndex !== -1) {
1972
+ const circle = this.circles[circleIndex]
1973
+ if (!circle) {
1974
+ return
1975
+ }
1976
+
1977
+ this.circles.splice(circleIndex, 1, {
1978
+ ...circle,
1979
+ member_count: (circle.member_count || 0) + 1
1980
+ })
1981
+ }
1982
+
1983
+ if (this.currentCircle?.id === circleId) {
1984
+ this.currentCircle = {
1985
+ ...this.currentCircle,
1986
+ member_count: (this.currentCircle.member_count || 0) + 1
1987
+ }
1988
+ }
1989
+ }
1990
+ } catch (error: unknown) {
1991
+ this.error = getErrorMessage(error)
1992
+ throw error
1993
+ }
1994
+ },
1995
+
1996
+ async syncPendingJoinRequestStatuses() {
1997
+ const circleIds = new Set<string>(this.pendingRequestCircleIds)
1998
+
1999
+ for (const circle of this.circles) {
2000
+ if (circle.has_pending_request) {
2001
+ circleIds.add(circle.id)
2002
+ }
2003
+ }
2004
+
2005
+ if (circleIds.size === 0) {
2006
+ return
2007
+ }
2008
+
2009
+ for (const circleId of circleIds) {
2010
+ try {
2011
+ const latestCircle = await circlesService.get(circleId)
2012
+
2013
+ const circleIndex = this.circles.findIndex(circle => circle.id === circleId)
2014
+ if (circleIndex !== -1) {
2015
+ const existingCircle = this.circles[circleIndex]
2016
+ if (!existingCircle) {
2017
+ continue
2018
+ }
2019
+
2020
+ this.circles.splice(circleIndex, 1, {
2021
+ ...existingCircle,
2022
+ ...latestCircle
2023
+ })
2024
+ }
2025
+
2026
+ if (this.currentCircle?.id === circleId) {
2027
+ this.currentCircle = {
2028
+ ...this.currentCircle,
2029
+ ...latestCircle
2030
+ }
2031
+ }
2032
+
2033
+ if (latestCircle.has_pending_request) {
2034
+ if (!this.pendingRequestCircleIds.includes(circleId)) {
2035
+ this.pendingRequestCircleIds.push(circleId)
2036
+ }
2037
+
2038
+ continue
2039
+ }
2040
+
2041
+ this.pendingRequestCircleIds = this.pendingRequestCircleIds.filter(id => id !== circleId)
2042
+ } catch {
2043
+ // Best-effort reconciliation: ignore per-circle failures and retry next poll cycle.
2044
+ }
2045
+ }
2046
+ },
2047
+
2048
+ async createInviteLink(circleId: string, options?: Pick<CircleInviteLinkOptions, 'max_uses' | 'expires_in_minutes'>): Promise<InviteLink> {
2049
+ this.error = null
2050
+
2051
+ try {
2052
+ return await circlesService.createInviteLink(circleId, options)
2053
+ } catch (error: unknown) {
2054
+ this.error = getErrorMessage(error)
2055
+ throw error
2056
+ }
2057
+ },
2058
+
2059
+ hasPendingRequest(circleId: string): boolean {
2060
+ return this.pendingRequestCircleIds.includes(circleId)
2061
+ },
2062
+
2063
+ clearState() {
2064
+ this.circles = []
2065
+ this.currentCircle = null
2066
+ this.members = {}
2067
+ this.joinRequests = {}
2068
+ this.joinRequestsPagination = {}
2069
+ this.managementActors = {}
2070
+ this.managementSections = {}
2071
+ this.managementBootstrapLoading = {}
2072
+ this.managementBootstrapError = {}
2073
+ this.managementSectionLoading = {}
2074
+ this.managementSectionErrors = {}
2075
+ this.managementBootstrappedAt = {}
2076
+ this.roleDefinitions = {}
2077
+ this.roleDefinitionsPagination = {}
2078
+ this.managementCounts = {}
2079
+ this.managementPermissionCatalog = {}
2080
+ this.roleAssignments = {}
2081
+ this.roleAssignmentsPagination = {}
2082
+ this.reports = {}
2083
+ this.reportsPagination = {}
2084
+ this.bans = {}
2085
+ this.bansPagination = {}
2086
+ this.mutes = {}
2087
+ this.mutesPagination = {}
2088
+ this.auditLog = {}
2089
+ this.auditLogPagination = {}
2090
+ this.automodRules = {}
2091
+ this.automodRulesPagination = {}
2092
+ this.pendingRequestCircleIds = []
2093
+ this.loading = false
2094
+ this.error = null
2095
+ this.pagination = {
2096
+ cursor: null,
2097
+ hasMore: true
2098
+ }
2099
+ this.activeFilterKey = DEFAULT_FILTER_KEY
2100
+ this.circlesByFilter = {
2101
+ [DEFAULT_FILTER_KEY]: []
2102
+ }
2103
+ this.paginationByFilter = {
2104
+ [DEFAULT_FILTER_KEY]: defaultPaginationState()
2105
+ }
2106
+ this.fetchedFilterKeys = {}
2107
+ this.membersPagination = {}
2108
+ this.filters = {}
2109
+ }
2110
+ },
2111
+
2112
+ ...(persist ? { persist } : {})
2113
+ })
2114
+ }