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
+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 };
}
}