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