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