diff --git a/apps/api/prisma/migrations/20260617220000_add_seva_gamification/migration.sql b/apps/api/prisma/migrations/20260617220000_add_seva_gamification/migration.sql new file mode 100644 index 0000000..a0e3065 --- /dev/null +++ b/apps/api/prisma/migrations/20260617220000_add_seva_gamification/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1b9e584..d0bc1dd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 389c978..9213c6e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/audit/audit.types.ts b/apps/api/src/modules/audit/audit.types.ts index 713b11f..9e3a6ed 100644 --- a/apps/api/src/modules/audit/audit.types.ts +++ b/apps/api/src/modules/audit/audit.types.ts @@ -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]; diff --git a/apps/api/src/modules/gamification/gamification.module.ts b/apps/api/src/modules/gamification/gamification.module.ts new file mode 100644 index 0000000..399649e --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GamificationService } from './gamification.service'; + +@Module({ + providers: [GamificationService], + exports: [GamificationService], +}) +export class GamificationModule {} diff --git a/apps/api/src/modules/gamification/gamification.service.ts b/apps/api/src/modules/gamification/gamification.service.ts new file mode 100644 index 0000000..edce2b4 --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.service.ts @@ -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 = {}; + + 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, + }; + }); + } +} diff --git a/apps/api/src/modules/gamification/gamification.types.ts b/apps/api/src/modules/gamification/gamification.types.ts new file mode 100644 index 0000000..4cd0280 --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.types.ts @@ -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 = { + 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; +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 607d443..3a4226e 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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) { diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts index 1c6bd75..8bc9a94 100644 --- a/apps/api/src/modules/my/my.module.ts +++ b/apps/api/src/modules/my/my.module.ts @@ -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], }) diff --git a/apps/api/src/modules/my/my.service.ts b/apps/api/src/modules/my/my.service.ts index 77b9689..dd09004 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -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 { @@ -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; } diff --git a/apps/api/src/modules/seva/seva.controller.ts b/apps/api/src/modules/seva/seva.controller.ts new file mode 100644 index 0000000..e7e2d90 --- /dev/null +++ b/apps/api/src/modules/seva/seva.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/seva/seva.module.ts b/apps/api/src/modules/seva/seva.module.ts new file mode 100644 index 0000000..baf9f59 --- /dev/null +++ b/apps/api/src/modules/seva/seva.module.ts @@ -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 {} diff --git a/apps/api/src/modules/seva/seva.service.ts b/apps/api/src/modules/seva/seva.service.ts new file mode 100644 index 0000000..3dbe8ca --- /dev/null +++ b/apps/api/src/modules/seva/seva.service.ts @@ -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 }; + } +} diff --git a/apps/web/app/api/admin/seva/[id]/entries/[entryId]/complete/route.ts b/apps/web/app/api/admin/seva/[id]/entries/[entryId]/complete/route.ts new file mode 100644 index 0000000..8cd008d --- /dev/null +++ b/apps/web/app/api/admin/seva/[id]/entries/[entryId]/complete/route.ts @@ -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 { + 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); +} diff --git a/apps/web/app/api/admin/seva/[id]/entries/route.ts b/apps/web/app/api/admin/seva/[id]/entries/route.ts new file mode 100644 index 0000000..9a6f624 --- /dev/null +++ b/apps/web/app/api/admin/seva/[id]/entries/route.ts @@ -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 { + const { id } = await params; + const res = await apiFetch(`/admin/seva/${id}/entries`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/admin/seva/[id]/route.ts b/apps/web/app/api/admin/seva/[id]/route.ts new file mode 100644 index 0000000..13a2641 --- /dev/null +++ b/apps/web/app/api/admin/seva/[id]/route.ts @@ -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 { + 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 { + const { id } = await params; + const res = await apiFetch(`/admin/seva/${id}`, { method: 'DELETE' }); + const resBody = await res.json(); + return jsonResponse(resBody, res.status); +} diff --git a/apps/web/app/api/admin/seva/route.ts b/apps/web/app/api/admin/seva/route.ts new file mode 100644 index 0000000..02f8226 --- /dev/null +++ b/apps/web/app/api/admin/seva/route.ts @@ -0,0 +1,20 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/seva'); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(request: Request): Promise { + 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); +} diff --git a/apps/web/app/api/my/seva/[id]/cancel/route.ts b/apps/web/app/api/my/seva/[id]/cancel/route.ts new file mode 100644 index 0000000..3fc6c16 --- /dev/null +++ b/apps/web/app/api/my/seva/[id]/cancel/route.ts @@ -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 { + const { id } = await params; + const res = await memberApiFetch(`/my/seva/${id}/cancel`, { method: 'POST' }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/seva/[id]/signup/route.ts b/apps/web/app/api/my/seva/[id]/signup/route.ts new file mode 100644 index 0000000..840b9b5 --- /dev/null +++ b/apps/web/app/api/my/seva/[id]/signup/route.ts @@ -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 { + const { id } = await params; + const res = await memberApiFetch(`/my/seva/${id}/signup`, { method: 'POST' }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/seva/route.ts b/apps/web/app/api/my/seva/route.ts new file mode 100644 index 0000000..1cfc67c --- /dev/null +++ b/apps/web/app/api/my/seva/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/seva'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index e92fb2e..aeffffb 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -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' }, ]; diff --git a/apps/web/app/my/seva/SevaSignupButton.tsx b/apps/web/app/my/seva/SevaSignupButton.tsx new file mode 100644 index 0000000..6a35c4b --- /dev/null +++ b/apps/web/app/my/seva/SevaSignupButton.tsx @@ -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(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 ✓ Completed; + } + + if (status === 'SIGNED_UP') { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/app/my/seva/page.tsx b/apps/web/app/my/seva/page.tsx new file mode 100644 index 0000000..7be5e99 --- /dev/null +++ b/apps/web/app/my/seva/page.tsx @@ -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; 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 { + 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 = { + 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 ( +
+ Could not load seva data. +
+ ); + } + + const { score, leaderboard, opportunities, myEntries } = data; + + return ( +
+
+

Seva & points

+ ← Dashboard +
+ + {/* Points hero */} +
+
+
+

Your points

+

{score.lifetime}

+

{score.recent} active · {score.eventCount} contributions

+
+
+ {Object.keys(score.byType).length > 0 && ( +
+ {Object.entries(score.byType).map(([type, pts]) => ( + + {TYPE_LABELS[type] ?? type}: {pts} + + ))} +
+ )} +
+ + {/* Open opportunities */} +
+

Open opportunities

+ {opportunities.length === 0 ? ( +

No open seva opportunities right now.

+ ) : ( +
+ {opportunities.map((o) => { + const full = o.slots != null && o.signupCount >= o.slots && o.myEntryStatus == null; + return ( +
+
+
+

{o.title}

+

+{o.pointsAward} points

+ {(o.location || o.startsAt) && ( +

+ {[o.location, o.startsAt ? new Date(o.startsAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : null] + .filter(Boolean) + .join(' · ')} +

+ )} +
+ +
+ {o.description &&

{o.description}

} +

+ {o.signupCount} signed up{o.slots != null ? ` · ${o.slots} slots` : ''} +

+
+ ); + })} +
+ )} +
+ + {/* Leaderboard */} + {leaderboard.length > 0 && ( +
+

Top contributors

+
+ {leaderboard.map((l) => ( +
+
+ + #{l.rank} + + {l.displayName} +
+ {l.points} pts +
+ ))} +
+
+ )} + + {/* My seva history */} + {myEntries.length > 0 && ( +
+

My seva

+
+ {myEntries.map((e) => ( +
+
+

{e.opportunityTitle}

+

+ {e.status === 'COMPLETED' && e.completedAt + ? `Completed ${new Date(e.completedAt).toLocaleDateString()}` + : e.status === 'SIGNED_UP' + ? 'Signed up' + : 'Cancelled'} +

+
+ {e.status === 'COMPLETED' && ( + + +{e.pointsAward} + + )} +
+ ))} +
+
+ )} +
+ ); +}