feat: member portal Sprint 6 — Seva + multi-signal gamification

- SevaOpportunity + SevaEntry + GamificationEvent models + migration
- GamificationService: multi-signal point table (helpful answer +10, seva +20,
  event +5, FAQ +15, welcome +3, profile +5, business +5), idempotent award via
  unique(userId,type,refId), lifetime + time-decayed "recent" score, leaderboard
- SevaModule: admin CRUD + mark-entry-complete (awards SEVA_COMPLETED points)
- Member endpoints in MyController: GET /my/seva, POST /my/seva/:id/signup|cancel
- PROFILE_COMPLETED auto-awarded on profile update (name+hometown+interest)
- /my/seva page: points hero with per-type breakdown, open opportunities w/ signup,
  leaderboard (respects directoryVisible), my seva history
- Member + admin BFF routes; Seva & Points nav item
- Register GamificationModule + SevaModule; add SEVA_* audit actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:44:43 +05:30
parent a0dc94ce79
commit cd1eef0098
23 changed files with 978 additions and 2 deletions
@@ -0,0 +1,59 @@
CREATE TYPE "SevaStatus" AS ENUM ('OPEN', 'CLOSED');
CREATE TYPE "SevaEntryStatus" AS ENUM ('SIGNED_UP', 'COMPLETED', 'CANCELLED');
CREATE TYPE "GamificationEventType" AS ENUM ('HELPFUL_ANSWER', 'SEVA_COMPLETED', 'EVENT_ATTENDANCE', 'FAQ_APPROVED', 'WELCOME', 'PROFILE_COMPLETED', 'BUSINESS_ADDED');
CREATE TABLE "SevaOpportunity" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"slots" INTEGER,
"startsAt" TIMESTAMP(3),
"pointsAward" INTEGER NOT NULL DEFAULT 20,
"status" "SevaStatus" NOT NULL DEFAULT 'OPEN',
"createdBy" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SevaOpportunity_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SevaEntry" (
"id" TEXT NOT NULL,
"opportunityId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "SevaEntryStatus" NOT NULL DEFAULT 'SIGNED_UP',
"hours" DOUBLE PRECISION,
"note" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SevaEntry_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GamificationEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "GamificationEventType" NOT NULL,
"points" INTEGER NOT NULL,
"refType" TEXT,
"refId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SevaOpportunity_tenantId_status_idx" ON "SevaOpportunity"("tenantId", "status");
CREATE INDEX "SevaOpportunity_tenantId_isPublished_idx" ON "SevaOpportunity"("tenantId", "isPublished");
CREATE UNIQUE INDEX "SevaEntry_opportunityId_userId_key" ON "SevaEntry"("opportunityId", "userId");
CREATE INDEX "SevaEntry_userId_idx" ON "SevaEntry"("userId");
CREATE INDEX "GamificationEvent_tenantId_userId_idx" ON "GamificationEvent"("tenantId", "userId");
CREATE INDEX "GamificationEvent_userId_createdAt_idx" ON "GamificationEvent"("userId", "createdAt");
CREATE UNIQUE INDEX "GamificationEvent_userId_type_refId_key" ON "GamificationEvent"("userId", "type", "refId");
ALTER TABLE "SevaOpportunity" ADD CONSTRAINT "SevaOpportunity_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SevaEntry" ADD CONSTRAINT "SevaEntry_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "SevaOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SevaEntry" ADD CONSTRAINT "SevaEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GamificationEvent" ADD CONSTRAINT "GamificationEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GamificationEvent" ADD CONSTRAINT "GamificationEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+87
View File
@@ -92,6 +92,8 @@ model Tenant {
digests Digest[]
events Event[]
threads Thread[]
sevaOpportunities SevaOpportunity[]
gamificationEvents GamificationEvent[]
}
enum AdminRole {
@@ -416,6 +418,8 @@ model TowerUser {
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
rsvps EventRsvp[]
sevaEntries SevaEntry[]
gamificationEvents GamificationEvent[]
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@ -602,3 +606,86 @@ model EventRsvp {
@@unique([eventId, userId])
@@index([userId])
}
// ============================================================================
// Seva (volunteering) + Gamification
// ============================================================================
enum SevaStatus {
OPEN
CLOSED
}
enum SevaEntryStatus {
SIGNED_UP
COMPLETED
CANCELLED
}
model SevaOpportunity {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
slots Int?
startsAt DateTime?
pointsAward Int @default(20)
status SevaStatus @default(OPEN)
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
entries SevaEntry[]
@@index([tenantId, status])
@@index([tenantId, isPublished])
}
model SevaEntry {
id String @id @default(cuid())
opportunityId String
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status SevaEntryStatus @default(SIGNED_UP)
hours Float?
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([opportunityId, userId])
@@index([userId])
}
// Multi-signal gamification — points come from many event types, not just seva.
enum GamificationEventType {
HELPFUL_ANSWER
SEVA_COMPLETED
EVENT_ATTENDANCE
FAQ_APPROVED
WELCOME
PROFILE_COMPLETED
BUSINESS_ADDED
}
model GamificationEvent {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
type GamificationEventType
points Int
refType String?
refId String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([userId, createdAt])
// One award per (user, type, ref) — prevents double-awarding the same source.
@@unique([userId, type, refId])
}
+4
View File
@@ -18,6 +18,8 @@ import { DigestModule } from './modules/digest/digest.module';
import { OrgModule } from './modules/org/org.module';
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';
@Module({
imports: [
@@ -40,6 +42,8 @@ import { EventsModule } from './modules/events/events.module';
OrgModule,
ThreadsModule,
EventsModule,
GamificationModule,
SevaModule,
],
})
export class AppModule {}
@@ -41,6 +41,9 @@ export const AuditAction = {
EVENT_UPDATED: 'EVENT_UPDATED',
EVENT_DELETED: 'EVENT_DELETED',
DIGEST_SENT: 'DIGEST_SENT',
SEVA_CREATED: 'SEVA_CREATED',
SEVA_DELETED: 'SEVA_DELETED',
SEVA_COMPLETED: 'SEVA_COMPLETED',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GamificationService } from './gamification.service';
@Module({
providers: [GamificationService],
exports: [GamificationService],
})
export class GamificationModule {}
@@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, GamificationEventType } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { POINTS, decayFactor } from './gamification.types';
interface AwardOptions {
points?: number;
refType?: string;
refId?: string;
}
@Injectable()
export class GamificationService {
private readonly logger = new Logger(GamificationService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Award points for an event. Idempotent per (userId, type, refId): if an award
* for the same source already exists, this is a no-op (returns null).
*/
async award(
tenantId: string,
userId: string,
type: GamificationEventType,
opts: AwardOptions = {},
) {
const points = opts.points ?? POINTS[type];
try {
return await this.prisma.gamificationEvent.create({
data: {
tenantId,
userId,
type,
points,
refType: opts.refType ?? null,
refId: opts.refId ?? null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
// Already awarded for this source — idempotent skip.
return null;
}
throw err;
}
}
async getUserScore(userId: string) {
const events = await this.prisma.gamificationEvent.findMany({
where: { userId },
select: { type: true, points: true, createdAt: true },
});
const now = Date.now();
let lifetime = 0;
let recent = 0;
const byType: Record<string, number> = {};
for (const e of events) {
lifetime += e.points;
const ageDays = (now - e.createdAt.getTime()) / 86_400_000;
recent += e.points * decayFactor(ageDays);
byType[e.type] = (byType[e.type] ?? 0) + e.points;
}
return {
lifetime,
recent: Math.round(recent),
byType,
eventCount: events.length,
};
}
async leaderboard(tenantId: string, limit = 10) {
const grouped = await this.prisma.gamificationEvent.groupBy({
by: ['userId'],
where: { tenantId },
_sum: { points: true },
orderBy: { _sum: { points: 'desc' } },
take: limit,
});
if (grouped.length === 0) return [];
const users = await this.prisma.towerUser.findMany({
where: { id: { in: grouped.map((g) => g.userId) } },
select: { id: true, displayName: true, directoryVisible: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return grouped.map((g, i) => {
const u = userMap.get(g.userId);
return {
rank: i + 1,
userId: g.userId,
displayName: u?.directoryVisible ? (u.displayName ?? 'Member') : 'Member',
points: g._sum.points ?? 0,
};
});
}
}
@@ -0,0 +1,19 @@
import { GamificationEventType } from '@prisma/client';
// Default point award per event type. Multi-signal — seva is one of many sources.
export const POINTS: Record<GamificationEventType, number> = {
HELPFUL_ANSWER: 10,
SEVA_COMPLETED: 20,
EVENT_ATTENDANCE: 5,
FAQ_APPROVED: 15,
WELCOME: 3,
PROFILE_COMPLETED: 5,
BUSINESS_ADDED: 5,
};
// Time-decay weighting for the "current standing" score (lifetime points are never decayed).
export function decayFactor(ageDays: number): number {
if (ageDays <= 30) return 1;
if (ageDays <= 90) return 0.5;
return 0.25;
}
+20 -1
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { MyService } from './my.service';
import { SevaService } from '../seva/seva.service';
import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types';
@@ -22,7 +23,25 @@ class OptInDto {
@Controller('my')
@MemberAuth()
export class MyController {
constructor(private readonly service: MyService) {}
constructor(
private readonly service: MyService,
private readonly seva: SevaService,
) {}
@Get('seva')
sevaList(@CurrentMember() member: MemberJwtPayload) {
return this.seva.listForMember(member.sub, member.tenantId);
}
@Post('seva/:id/signup')
sevaSignup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.seva.signup(member.sub, member.tenantId, id);
}
@Post('seva/:id/cancel')
sevaCancel(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.seva.cancel(member.sub, member.tenantId, id);
}
@Get('dashboard')
dashboard(@CurrentMember() member: MemberJwtPayload) {
+3 -1
View File
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { MyController } from './my.controller';
import { MyService } from './my.service';
import { AuthModule } from '../auth/auth.module';
import { SevaModule } from '../seva/seva.module';
import { GamificationModule } from '../gamification/gamification.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, SevaModule, GamificationModule],
controllers: [MyController],
providers: [MyService],
})
+11
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { GamificationService } from '../gamification/gamification.service';
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
@@ -12,6 +13,7 @@ export class MyService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly gamification: GamificationService,
) {}
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
@@ -96,6 +98,15 @@ export class MyService {
},
});
// Award PROFILE_COMPLETED once the profile carries real signal (name + hometown + an interest).
// Idempotent via refId=userId, so re-saving never double-awards.
if (updated.displayName && updated.hometown && updated.interests.length > 0) {
await this.gamification.award(tenantId, userId, 'PROFILE_COMPLETED', {
refType: 'TowerUser',
refId: userId,
});
}
return updated;
}
@@ -0,0 +1,73 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { SevaService } from './seva.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/seva')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class SevaController {
constructor(private readonly service: SevaService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAdmin(ctx.tenantId);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: {
title: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
},
) {
return this.service.create(ctx.tenantId, ctx.adminId!, body);
}
@Patch(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: {
title?: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
status?: 'OPEN' | 'CLOSED';
},
) {
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/entries')
entries(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.listEntries(ctx.tenantId, id);
}
@Post(':id/entries/:entryId/complete')
complete(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('entryId') entryId: string,
@Body() body: { hours?: number },
) {
return this.service.completeEntry(ctx.tenantId, ctx.adminId!, id, entryId, body.hours);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SevaController } from './seva.controller';
import { SevaService } from './seva.service';
import { AuthModule } from '../auth/auth.module';
import { GamificationModule } from '../gamification/gamification.module';
@Module({
imports: [AuthModule, GamificationModule],
controllers: [SevaController],
providers: [SevaService],
exports: [SevaService],
})
export class SevaModule {}
+224
View File
@@ -0,0 +1,224 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { GamificationService } from '../gamification/gamification.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class SevaService {
constructor(
private readonly prisma: PrismaService,
private readonly gamification: GamificationService,
private readonly audit: AuditService,
) {}
// ── Admin ────────────────────────────────────────────────────────────────
async listAdmin(tenantId: string) {
return this.prisma.sevaOpportunity.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { entries: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
}) {
const opp = await this.prisma.sevaOpportunity.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
location: dto.location ?? null,
slots: dto.slots ?? null,
startsAt: dto.startsAt ? new Date(dto.startsAt) : null,
pointsAward: dto.pointsAward ?? 20,
isPublished: dto.isPublished ?? false,
createdBy: adminId,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_CREATED,
resourceType: 'SevaOpportunity',
resourceId: opp.id,
payload: { title: opp.title },
});
return opp;
}
async update(tenantId: string, adminId: string, id: string, dto: {
title?: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
status?: 'OPEN' | 'CLOSED';
}) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
return this.prisma.sevaOpportunity.update({
where: { id },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.slots !== undefined && { slots: dto.slots }),
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
...(dto.pointsAward !== undefined && { pointsAward: dto.pointsAward }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
...(dto.status !== undefined && { status: dto.status }),
},
});
}
async remove(tenantId: string, adminId: string, id: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
await this.prisma.sevaOpportunity.delete({ where: { id } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_DELETED,
resourceType: 'SevaOpportunity',
resourceId: id,
});
return { ok: true };
}
async listEntries(tenantId: string, opportunityId: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
return this.prisma.sevaEntry.findMany({
where: { opportunityId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: { createdAt: 'asc' },
});
}
/** Admin marks an entry completed → awards SEVA_COMPLETED points (idempotent). */
async completeEntry(tenantId: string, adminId: string, opportunityId: string, entryId: string, hours?: number) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
const entry = await this.prisma.sevaEntry.findFirst({ where: { id: entryId, opportunityId } });
if (!entry) throw new NotFoundException('Entry not found');
const updated = await this.prisma.sevaEntry.update({
where: { id: entryId },
data: { status: 'COMPLETED', completedAt: new Date(), ...(hours !== undefined && { hours }) },
});
await this.gamification.award(tenantId, entry.userId, 'SEVA_COMPLETED', {
points: opp.pointsAward,
refType: 'SevaEntry',
refId: entry.id,
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_COMPLETED,
resourceType: 'SevaEntry',
resourceId: entry.id,
payload: { opportunityId, userId: entry.userId, points: opp.pointsAward },
});
return updated;
}
// ── Member ─────────────────────────────────────────────────────────────────
async listForMember(userId: string, tenantId: string) {
const opportunities = await this.prisma.sevaOpportunity.findMany({
where: { tenantId, isPublished: true, status: 'OPEN' },
orderBy: [{ startsAt: 'asc' }, { createdAt: 'desc' }],
include: {
entries: { where: { userId }, select: { id: true, status: true } },
_count: { select: { entries: true } },
},
});
const myEntries = await this.prisma.sevaEntry.findMany({
where: { userId, opportunity: { tenantId } },
include: { opportunity: { select: { title: true, pointsAward: true } } },
orderBy: { createdAt: 'desc' },
});
const [score, leaderboard] = await Promise.all([
this.gamification.getUserScore(userId),
this.gamification.leaderboard(tenantId, 5),
]);
return {
score,
leaderboard,
opportunities: opportunities.map((o) => ({
id: o.id,
title: o.title,
description: o.description,
location: o.location,
slots: o.slots,
startsAt: o.startsAt?.toISOString() ?? null,
pointsAward: o.pointsAward,
signupCount: o._count.entries,
myEntryStatus: o.entries[0]?.status ?? null,
})),
myEntries: myEntries.map((e) => ({
id: e.id,
opportunityTitle: e.opportunity.title,
status: e.status,
hours: e.hours,
pointsAward: e.opportunity.pointsAward,
completedAt: e.completedAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
})),
};
}
async signup(userId: string, tenantId: string, opportunityId: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({
where: { id: opportunityId, tenantId, isPublished: true, status: 'OPEN' },
include: { _count: { select: { entries: true } } },
});
if (!opp) throw new NotFoundException('Opportunity not available');
if (opp.slots != null && opp._count.entries >= opp.slots) {
const existing = await this.prisma.sevaEntry.findUnique({
where: { opportunityId_userId: { opportunityId, userId } },
});
if (!existing) throw new BadRequestException('This opportunity is full');
}
const entry = await this.prisma.sevaEntry.upsert({
where: { opportunityId_userId: { opportunityId, userId } },
create: { opportunityId, userId, status: 'SIGNED_UP' },
update: { status: 'SIGNED_UP' },
});
return { ok: true, entryId: entry.id, status: entry.status };
}
async cancel(userId: string, tenantId: string, opportunityId: string) {
const entry = await this.prisma.sevaEntry.findFirst({
where: { opportunityId, userId, opportunity: { tenantId } },
});
if (!entry) throw new NotFoundException('You are not signed up for this');
if (entry.status === 'COMPLETED') throw new BadRequestException('Cannot cancel a completed seva');
await this.prisma.sevaEntry.update({
where: { id: entry.id },
data: { status: 'CANCELLED' },
});
return { ok: true };
}
}
@@ -0,0 +1,18 @@
import { apiFetch, jsonResponse } from '../../../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string; entryId: string }> },
): Promise<Response> {
const { id, entryId } = await params;
const body = await request.json().catch(() => ({}));
const res = await apiFetch(`/admin/seva/${id}/entries/${entryId}/complete`, {
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 { 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/seva/${id}/entries`);
const body = await res.json();
return jsonResponse(body, res.status);
}
+28
View File
@@ -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/seva/${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/seva/${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/seva');
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/seva', {
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/seva/${id}/cancel`, { 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/seva/${id}/signup`, { 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/seva');
const body = await res.json();
return jsonResponse(body, res.status);
}
+1
View File
@@ -6,6 +6,7 @@ const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
type EntryStatus = 'SIGNED_UP' | 'COMPLETED' | 'CANCELLED' | null;
interface Props {
opportunityId: string;
initialStatus: EntryStatus;
full: boolean;
}
export function SevaSignupButton({ opportunityId, initialStatus, full }: Props) {
const router = useRouter();
const [status, setStatus] = useState<EntryStatus>(initialStatus);
const [loading, setLoading] = useState(false);
const act = async (action: 'signup' | 'cancel') => {
setLoading(true);
try {
const res = await fetch(`/api/my/seva/${opportunityId}/${action}`, { method: 'POST' });
if (res.ok) {
setStatus(action === 'signup' ? 'SIGNED_UP' : 'CANCELLED');
router.refresh();
}
} finally {
setLoading(false);
}
};
if (status === 'COMPLETED') {
return <span className="text-xs bg-green-100 text-green-700 rounded-full px-3 py-1.5 font-medium"> Completed</span>;
}
if (status === 'SIGNED_UP') {
return (
<button
type="button"
disabled={loading}
onClick={() => act('cancel')}
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 ? '…' : 'Signed up · Cancel'}
</button>
);
}
return (
<button
type="button"
disabled={loading || full}
onClick={() => act('signup')}
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"
>
{full ? 'Full' : loading ? '…' : 'Sign up'}
</button>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { SevaSignupButton } from './SevaSignupButton';
import Link from 'next/link';
type EntryStatus = 'SIGNED_UP' | 'COMPLETED' | 'CANCELLED';
interface SevaData {
score: { lifetime: number; recent: number; byType: Record<string, number>; eventCount: number };
leaderboard: Array<{ rank: number; userId: string; displayName: string; points: number }>;
opportunities: Array<{
id: string;
title: string;
description: string | null;
location: string | null;
slots: number | null;
startsAt: string | null;
pointsAward: number;
signupCount: number;
myEntryStatus: EntryStatus | null;
}>;
myEntries: Array<{
id: string;
opportunityTitle: string;
status: EntryStatus;
hours: number | null;
pointsAward: number;
completedAt: string | null;
createdAt: string;
}>;
}
async function fetchSeva(token: string): Promise<SevaData | null> {
const res = await fetch(`${getApiBaseUrl()}/my/seva`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as SevaData;
}
const TYPE_LABELS: Record<string, string> = {
HELPFUL_ANSWER: 'Helpful answers',
SEVA_COMPLETED: 'Seva completed',
EVENT_ATTENDANCE: 'Event attendance',
FAQ_APPROVED: 'FAQ contributions',
WELCOME: 'Welcomes',
PROFILE_COMPLETED: 'Profile',
BUSINESS_ADDED: 'Business',
};
export default async function SevaPage() {
const token = await getMemberToken();
if (!token) return null;
const data = await fetchSeva(token);
if (!data) {
return (
<div className="max-w-2xl mx-auto mt-12 text-center text-gray-400 text-sm">
Could not load seva data.
</div>
);
}
const { score, leaderboard, opportunities, myEntries } = data;
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">Seva &amp; points</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
{/* Points hero */}
<div className="bg-gradient-to-br from-indigo-500 to-indigo-700 rounded-xl p-6 text-white">
<div className="flex items-end justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-indigo-200">Your points</p>
<p className="text-4xl font-bold mt-1">{score.lifetime}</p>
<p className="text-xs text-indigo-200 mt-1">{score.recent} active · {score.eventCount} contributions</p>
</div>
</div>
{Object.keys(score.byType).length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{Object.entries(score.byType).map(([type, pts]) => (
<span key={type} className="text-xs bg-white/15 rounded-full px-2.5 py-1">
{TYPE_LABELS[type] ?? type}: {pts}
</span>
))}
</div>
)}
</div>
{/* Open opportunities */}
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Open opportunities</h2>
{opportunities.length === 0 ? (
<p className="text-sm text-gray-400 py-6 text-center">No open seva opportunities right now.</p>
) : (
<div className="space-y-3">
{opportunities.map((o) => {
const full = o.slots != null && o.signupCount >= o.slots && o.myEntryStatus == null;
return (
<div key={o.id} 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">
<p className="text-sm font-semibold text-gray-900">{o.title}</p>
<p className="text-xs text-indigo-600 mt-0.5">+{o.pointsAward} points</p>
{(o.location || o.startsAt) && (
<p className="text-xs text-gray-400 mt-0.5">
{[o.location, o.startsAt ? new Date(o.startsAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : null]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
<SevaSignupButton opportunityId={o.id} initialStatus={o.myEntryStatus} full={full} />
</div>
{o.description && <p className="text-sm text-gray-600 mt-2 leading-relaxed">{o.description}</p>}
<p className="text-xs text-gray-400 mt-2">
{o.signupCount} signed up{o.slots != null ? ` · ${o.slots} slots` : ''}
</p>
</div>
);
})}
</div>
)}
</section>
{/* Leaderboard */}
{leaderboard.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Top contributors</h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{leaderboard.map((l) => (
<div key={l.userId} className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<span className={`text-sm font-bold w-6 ${l.rank <= 3 ? 'text-indigo-600' : 'text-gray-400'}`}>
#{l.rank}
</span>
<span className="text-sm text-gray-800">{l.displayName}</span>
</div>
<span className="text-sm font-medium text-gray-500">{l.points} pts</span>
</div>
))}
</div>
</section>
)}
{/* My seva history */}
{myEntries.length > 0 && (
<section>
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">My seva</h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{myEntries.map((e) => (
<div key={e.id} className="flex items-center justify-between px-5 py-3">
<div>
<p className="text-sm text-gray-800">{e.opportunityTitle}</p>
<p className="text-xs text-gray-400">
{e.status === 'COMPLETED' && e.completedAt
? `Completed ${new Date(e.completedAt).toLocaleDateString()}`
: e.status === 'SIGNED_UP'
? 'Signed up'
: 'Cancelled'}
</p>
</div>
{e.status === 'COMPLETED' && (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2.5 py-1 font-medium">
+{e.pointsAward}
</span>
)}
</div>
))}
</div>
</section>
)}
</div>
);
}