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:
@@ -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;
|
||||||
@@ -94,6 +94,7 @@ model Tenant {
|
|||||||
threads Thread[]
|
threads Thread[]
|
||||||
sevaOpportunities SevaOpportunity[]
|
sevaOpportunities SevaOpportunity[]
|
||||||
gamificationEvents GamificationEvent[]
|
gamificationEvents GamificationEvent[]
|
||||||
|
circles Circle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdminRole {
|
enum AdminRole {
|
||||||
@@ -420,6 +421,7 @@ model TowerUser {
|
|||||||
rsvps EventRsvp[]
|
rsvps EventRsvp[]
|
||||||
sevaEntries SevaEntry[]
|
sevaEntries SevaEntry[]
|
||||||
gamificationEvents GamificationEvent[]
|
gamificationEvents GamificationEvent[]
|
||||||
|
circleMemberships CircleMembership[]
|
||||||
|
|
||||||
@@unique([tenantId, phoneHash])
|
@@unique([tenantId, phoneHash])
|
||||||
@@index([phoneHash])
|
@@index([phoneHash])
|
||||||
@@ -689,3 +691,42 @@ model GamificationEvent {
|
|||||||
// One award per (user, type, ref) — prevents double-awarding the same source.
|
// One award per (user, type, ref) — prevents double-awarding the same source.
|
||||||
@@unique([userId, type, refId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ThreadsModule } from './modules/threads/threads.module';
|
|||||||
import { EventsModule } from './modules/events/events.module';
|
import { EventsModule } from './modules/events/events.module';
|
||||||
import { GamificationModule } from './modules/gamification/gamification.module';
|
import { GamificationModule } from './modules/gamification/gamification.module';
|
||||||
import { SevaModule } from './modules/seva/seva.module';
|
import { SevaModule } from './modules/seva/seva.module';
|
||||||
|
import { CirclesModule } from './modules/circles/circles.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,6 +45,7 @@ import { SevaModule } from './modules/seva/seva.module';
|
|||||||
EventsModule,
|
EventsModule,
|
||||||
GamificationModule,
|
GamificationModule,
|
||||||
SevaModule,
|
SevaModule,
|
||||||
|
CirclesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const AuditAction = {
|
|||||||
SEVA_CREATED: 'SEVA_CREATED',
|
SEVA_CREATED: 'SEVA_CREATED',
|
||||||
SEVA_DELETED: 'SEVA_DELETED',
|
SEVA_DELETED: 'SEVA_DELETED',
|
||||||
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
||||||
|
CIRCLE_CREATED: 'CIRCLE_CREATED',
|
||||||
|
CIRCLE_DELETED: 'CIRCLE_DELETED',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
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 { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
import { MyService } from './my.service';
|
import { MyService } from './my.service';
|
||||||
import { SevaService } from '../seva/seva.service';
|
import { SevaService } from '../seva/seva.service';
|
||||||
|
import { CirclesService } from '../circles/circles.service';
|
||||||
import { MemberAuth } from '../auth/member-auth.decorator';
|
import { MemberAuth } from '../auth/member-auth.decorator';
|
||||||
import { CurrentMember } from '../auth/current-member.decorator';
|
import { CurrentMember } from '../auth/current-member.decorator';
|
||||||
import type { MemberJwtPayload } from '@tower/types';
|
import type { MemberJwtPayload } from '@tower/types';
|
||||||
@@ -26,6 +27,7 @@ export class MyController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly service: MyService,
|
private readonly service: MyService,
|
||||||
private readonly seva: SevaService,
|
private readonly seva: SevaService,
|
||||||
|
private readonly circles: CirclesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('seva')
|
@Get('seva')
|
||||||
@@ -43,6 +45,21 @@ export class MyController {
|
|||||||
return this.seva.cancel(member.sub, member.tenantId, id);
|
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')
|
@Get('dashboard')
|
||||||
dashboard(@CurrentMember() member: MemberJwtPayload) {
|
dashboard(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getDashboard(member.sub, member.tenantId);
|
return this.service.getDashboard(member.sub, member.tenantId);
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { MyService } from './my.service';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { SevaModule } from '../seva/seva.module';
|
import { SevaModule } from '../seva/seva.module';
|
||||||
import { GamificationModule } from '../gamification/gamification.module';
|
import { GamificationModule } from '../gamification/gamification.module';
|
||||||
|
import { CirclesModule } from '../circles/circles.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, SevaModule, GamificationModule],
|
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule],
|
||||||
controllers: [MyController],
|
controllers: [MyController],
|
||||||
providers: [MyService],
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const NAV = [
|
|||||||
{ href: '/my/digest', label: 'Digest' },
|
{ href: '/my/digest', label: 'Digest' },
|
||||||
{ href: '/my/events', label: 'Events' },
|
{ href: '/my/events', label: 'Events' },
|
||||||
{ href: '/my/seva', label: 'Seva & Points' },
|
{ href: '/my/seva', label: 'Seva & Points' },
|
||||||
|
{ href: '/my/circles', label: 'Circles' },
|
||||||
{ href: '/my/groups', label: 'My Groups' },
|
{ href: '/my/groups', label: 'My Groups' },
|
||||||
{ href: '/my/settings', label: 'Settings' },
|
{ href: '/my/settings', label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user