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