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],
})
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/circles/${id}/members`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await apiFetch(`/admin/circles/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/circles/${id}`, { method: 'DELETE' });
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/circles');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await apiFetch('/admin/circles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/circles/${id}/join`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/circles/${id}/leave`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/circles');
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,53 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface Props {
circleId: string;
initialMember: boolean;
isPublic: boolean;
}
export function CircleJoinButton({ circleId, initialMember, isPublic }: Props) {
const router = useRouter();
const [member, setMember] = useState(initialMember);
const [loading, setLoading] = useState(false);
const act = async (action: 'join' | 'leave') => {
setLoading(true);
try {
const res = await fetch(`/api/my/circles/${circleId}/${action}`, { method: 'POST' });
if (res.ok) {
setMember(action === 'join');
router.refresh();
}
} finally {
setLoading(false);
}
};
if (member) {
return (
<button
type="button"
disabled={loading}
onClick={() => act('leave')}
className="text-xs rounded-full px-3 py-1.5 font-medium border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{loading ? '…' : 'Joined · Leave'}
</button>
);
}
return (
<button
type="button"
disabled={loading || !isPublic}
onClick={() => act('join')}
className="text-xs rounded-full px-4 py-1.5 font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{!isPublic ? 'Invite only' : loading ? '…' : 'Join'}
</button>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { CircleJoinButton } from './CircleJoinButton';
import Link from 'next/link';
interface CircleItem {
id: string;
name: string;
description: string | null;
isPublic: boolean;
memberCount: number;
isMember: boolean;
myRole: 'MEMBER' | 'LEAD' | null;
}
async function fetchCircles(token: string): Promise<CircleItem[]> {
const res = await fetch(`${getApiBaseUrl()}/my/circles`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as CircleItem[];
}
function CircleCard({ c }: { c: CircleItem }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900">{c.name}</p>
{c.myRole === 'LEAD' && (
<span className="text-[10px] bg-amber-100 text-amber-700 rounded-full px-2 py-0.5 font-medium uppercase tracking-wide">Lead</span>
)}
{!c.isPublic && (
<span className="text-[10px] bg-gray-100 text-gray-500 rounded-full px-2 py-0.5 font-medium">Private</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">
{c.memberCount} member{c.memberCount !== 1 ? 's' : ''}
</p>
</div>
<CircleJoinButton circleId={c.id} initialMember={c.isMember} isPublic={c.isPublic} />
</div>
{c.description && <p className="text-sm text-gray-600 mt-2 leading-relaxed">{c.description}</p>}
</div>
);
}
export default async function CirclesPage() {
const token = await getMemberToken();
if (!token) return null;
const circles = await fetchCircles(token);
const mine = circles.filter((c) => c.isMember);
const discover = circles.filter((c) => !c.isMember);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Circles</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
{circles.length === 0 && (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">No circles yet.</p>
<p className="text-xs mt-1">Your chapter admin will create interest circles you can join.</p>
</div>
)}
{mine.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">My circles</h2>
<div className="space-y-3">
{mine.map((c) => <CircleCard key={c.id} c={c} />)}
</div>
</section>
)}
{discover.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Discover</h2>
<div className="space-y-3">
{discover.map((c) => <CircleCard key={c.id} c={c} />)}
</div>
</section>
)}
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ const NAV = [
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/circles', label: 'Circles' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];