feat: member portal Sprint 7 — Circles (interest/affinity sub-groups)

- Circle + CircleMembership models + migration (CircleRole: MEMBER/LEAD, public/invite-only)
- CirclesModule: admin CRUD + member list; unique circle name per tenant
- Member endpoints in MyController: GET /my/circles, POST /my/circles/:id/join|leave
- listForMember returns public circles + private ones the member belongs to, with isMember/myRole/memberCount
- join() enforces invite-only; leave() removes membership
- /my/circles page: "My circles" + "Discover" split, join/leave button, LEAD + Private badges
- Member + admin BFF routes; Circles nav item
- Register CirclesModule; add CIRCLE_CREATED/DELETED audit actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:52:04 +05:30
parent cd1eef0098
commit 57fe9d9bf1
18 changed files with 538 additions and 1 deletions
+41
View File
@@ -94,6 +94,7 @@ model Tenant {
threads Thread[]
sevaOpportunities SevaOpportunity[]
gamificationEvents GamificationEvent[]
circles Circle[]
}
enum AdminRole {
@@ -420,6 +421,7 @@ model TowerUser {
rsvps EventRsvp[]
sevaEntries SevaEntry[]
gamificationEvents GamificationEvent[]
circleMemberships CircleMembership[]
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@ -689,3 +691,42 @@ model GamificationEvent {
// One award per (user, type, ref) — prevents double-awarding the same source.
@@unique([userId, type, refId])
}
// ============================================================================
// Circles (interest / affinity sub-groups within a chapter)
// ============================================================================
enum CircleRole {
MEMBER
LEAD
}
model Circle {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
isPublic Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships CircleMembership[]
@@unique([tenantId, name])
@@index([tenantId, isPublic])
}
model CircleMembership {
id String @id @default(cuid())
circleId String
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
role CircleRole @default(MEMBER)
createdAt DateTime @default(now())
@@unique([circleId, userId])
@@index([userId])
}