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
@@ -0,0 +1,31 @@
CREATE TYPE "CircleRole" AS ENUM ('MEMBER', 'LEAD');
CREATE TABLE "Circle" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Circle_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "CircleMembership" (
"id" TEXT NOT NULL,
"circleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "CircleRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CircleMembership_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Circle_tenantId_name_key" ON "Circle"("tenantId", "name");
CREATE INDEX "Circle_tenantId_isPublic_idx" ON "Circle"("tenantId", "isPublic");
CREATE UNIQUE INDEX "CircleMembership_circleId_userId_key" ON "CircleMembership"("circleId", "userId");
CREATE INDEX "CircleMembership_userId_idx" ON "CircleMembership"("userId");
ALTER TABLE "Circle" ADD CONSTRAINT "Circle_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_circleId_fkey" FOREIGN KEY ("circleId") REFERENCES "Circle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+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])
}
+2
View File
@@ -20,6 +20,7 @@ import { ThreadsModule } from './modules/threads/threads.module';
import { EventsModule } from './modules/events/events.module';
import { GamificationModule } from './modules/gamification/gamification.module';
import { SevaModule } from './modules/seva/seva.module';
import { CirclesModule } from './modules/circles/circles.module';
@Module({
imports: [
@@ -44,6 +45,7 @@ import { SevaModule } from './modules/seva/seva.module';
EventsModule,
GamificationModule,
SevaModule,
CirclesModule,
],
})
export class AppModule {}
@@ -44,6 +44,8 @@ export const AuditAction = {
SEVA_CREATED: 'SEVA_CREATED',
SEVA_DELETED: 'SEVA_DELETED',
SEVA_COMPLETED: 'SEVA_COMPLETED',
CIRCLE_CREATED: 'CIRCLE_CREATED',
CIRCLE_DELETED: 'CIRCLE_DELETED',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { CirclesService } from './circles.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('admin/circles')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class CirclesController {
constructor(private readonly service: CirclesService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAdmin(ctx.tenantId);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: { name: string; description?: string; isPublic?: boolean },
) {
return this.service.create(ctx.tenantId, ctx.adminId!, body);
}
@Patch(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { name?: string; description?: string; isPublic?: boolean },
) {
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
}
@Delete(':id')
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
}
@Get(':id/members')
members(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.listMembers(ctx.tenantId, id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CirclesController } from './circles.controller';
import { CirclesService } from './circles.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [CirclesController],
providers: [CirclesService],
exports: [CirclesService],
})
export class CirclesModule {}
@@ -0,0 +1,145 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class CirclesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
// ── Admin ────────────────────────────────────────────────────────────────
async listAdmin(tenantId: string) {
return this.prisma.circle.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { memberships: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
name: string;
description?: string;
isPublic?: boolean;
}) {
const existing = await this.prisma.circle.findFirst({ where: { tenantId, name: dto.name } });
if (existing) throw new BadRequestException('A circle with this name already exists');
const circle = await this.prisma.circle.create({
data: {
tenantId,
name: dto.name,
description: dto.description ?? null,
isPublic: dto.isPublic ?? true,
createdBy: adminId,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.CIRCLE_CREATED,
resourceType: 'Circle',
resourceId: circle.id,
payload: { name: circle.name },
});
return circle;
}
async update(tenantId: string, adminId: string, id: string, dto: {
name?: string;
description?: string;
isPublic?: boolean;
}) {
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
return this.prisma.circle.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
},
});
}
async remove(tenantId: string, adminId: string, id: string) {
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
await this.prisma.circle.delete({ where: { id } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.CIRCLE_DELETED,
resourceType: 'Circle',
resourceId: id,
});
return { ok: true };
}
async listMembers(tenantId: string, circleId: string) {
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
return this.prisma.circleMembership.findMany({
where: { circleId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
}
// ── Member ─────────────────────────────────────────────────────────────────
async listForMember(userId: string, tenantId: string) {
const circles = await this.prisma.circle.findMany({
where: {
tenantId,
// public circles, plus any private circle the member already belongs to
OR: [{ isPublic: true }, { memberships: { some: { userId } } }],
},
orderBy: { name: 'asc' },
include: {
memberships: { where: { userId }, select: { role: true } },
_count: { select: { memberships: true } },
},
});
return circles.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
isPublic: c.isPublic,
memberCount: c._count.memberships,
isMember: c.memberships.length > 0,
myRole: c.memberships[0]?.role ?? null,
}));
}
async join(userId: string, tenantId: string, circleId: string) {
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
if (!circle.isPublic) {
const existing = await this.prisma.circleMembership.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (!existing) throw new ForbiddenException('This circle is invite-only');
}
const membership = await this.prisma.circleMembership.upsert({
where: { circleId_userId: { circleId, userId } },
create: { circleId, userId, role: 'MEMBER' },
update: {},
});
return { ok: true, membershipId: membership.id };
}
async leave(userId: string, tenantId: string, circleId: string) {
const membership = await this.prisma.circleMembership.findFirst({
where: { circleId, userId, circle: { tenantId } },
});
if (!membership) throw new NotFoundException('You are not a member of this circle');
await this.prisma.circleMembership.delete({ where: { id: membership.id } });
return { ok: true };
}
}
+17
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { MyService } from './my.service';
import { SevaService } from '../seva/seva.service';
import { CirclesService } from '../circles/circles.service';
import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types';
@@ -26,6 +27,7 @@ export class MyController {
constructor(
private readonly service: MyService,
private readonly seva: SevaService,
private readonly circles: CirclesService,
) {}
@Get('seva')
@@ -43,6 +45,21 @@ export class MyController {
return this.seva.cancel(member.sub, member.tenantId, id);
}
@Get('circles')
circlesList(@CurrentMember() member: MemberJwtPayload) {
return this.circles.listForMember(member.sub, member.tenantId);
}
@Post('circles/:id/join')
circleJoin(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.circles.join(member.sub, member.tenantId, id);
}
@Post('circles/:id/leave')
circleLeave(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.circles.leave(member.sub, member.tenantId, id);
}
@Get('dashboard')
dashboard(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDashboard(member.sub, member.tenantId);
+2 -1
View File
@@ -4,9 +4,10 @@ import { MyService } from './my.service';
import { AuthModule } from '../auth/auth.module';
import { SevaModule } from '../seva/seva.module';
import { GamificationModule } from '../gamification/gamification.module';
import { CirclesModule } from '../circles/circles.module';
@Module({
imports: [AuthModule, SevaModule, GamificationModule],
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule],
controllers: [MyController],
providers: [MyService],
})