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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user