diff --git a/prisma/migrations/20260719034157_add_event_ticket_admins/migration.sql b/prisma/migrations/20260719034157_add_event_ticket_admins/migration.sql new file mode 100644 index 0000000..ab16ee1 --- /dev/null +++ b/prisma/migrations/20260719034157_add_event_ticket_admins/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "TicketAdminStatus" AS ENUM ('ACTIVE', 'REVOKED'); + +-- AlterEnum +ALTER TYPE "BlockchainTxType" ADD VALUE 'SET_ADMINS'; + +-- CreateTable +CREATE TABLE "event_ticket_admins" ( + "id" UUID NOT NULL, + "event_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "address" TEXT NOT NULL, + "status" "TicketAdminStatus" NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "event_ticket_admins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "event_ticket_admins_event_id_idx" ON "event_ticket_admins"("event_id"); + +-- CreateIndex +CREATE INDEX "event_ticket_admins_user_id_idx" ON "event_ticket_admins"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "event_ticket_admins_event_id_user_id_key" ON "event_ticket_admins"("event_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "event_ticket_admins" ADD CONSTRAINT "event_ticket_admins_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_ticket_admins" ADD CONSTRAINT "event_ticket_admins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260719051500_ticket_onchain_id_unique_per_event/migration.sql b/prisma/migrations/20260719051500_ticket_onchain_id_unique_per_event/migration.sql new file mode 100644 index 0000000..b36d8c8 --- /dev/null +++ b/prisma/migrations/20260719051500_ticket_onchain_id_unique_per_event/migration.sql @@ -0,0 +1,9 @@ +-- on_chain_ticket_id is only unique per chain; an event is bound to one +-- chain, so scope uniqueness to (event_id, on_chain_ticket_id). The old +-- global unique collided across chains (e.g. Arc id 1 vs Base id 1). + +-- DropIndex +DROP INDEX "ticket_types_on_chain_ticket_id_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "ticket_types_event_id_on_chain_ticket_id_key" ON "ticket_types"("event_id", "on_chain_ticket_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7108c5..eac41f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,12 @@ enum BlockchainTxType { CHECKIN WITHDRAW SET_FEES + SET_ADMINS +} + +enum TicketAdminStatus { + ACTIVE + REVOKED } enum BlockchainTxStatus { @@ -156,6 +162,7 @@ model User { payouts Payout[] notifications Notification[] wallets UserWallet[] + ticketAdminFor EventTicketAdmin[] @@map("users") } @@ -257,11 +264,37 @@ model Event { transactions Transaction[] payouts Payout[] blockchainTransactions BlockchainTransaction[] + ticketAdmins EventTicketAdmin[] @@index([organizerId]) @@map("events") } +/// Organizer-delegated check-in admins for an event. On-chain the grant +/// is per ticket type (addTicketAdmins is keyed by the on-chain ticket +/// id), but delegation is modelled per (event, user) here since an +/// organizer delegates for the whole event. `address` is the user's +/// wallet on the event's chain, granted the on-chain ticketAdmin role. +/// The contract has no getter for the admin set, so this table is the +/// source of truth for listing delegates. +model EventTicketAdmin { + id String @id @default(uuid()) @db.Uuid + eventId String @map("event_id") @db.Uuid + userId String @map("user_id") @db.Uuid + address String + status TicketAdminStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + event Event @relation(fields: [eventId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([eventId, userId]) + @@index([eventId]) + @@index([userId]) + @@map("event_ticket_admins") +} + model TicketType { id String @id @default(uuid()) @db.Uuid eventId String @map("event_id") @db.Uuid @@ -276,13 +309,18 @@ model TicketType { // On-chain Diamond ticketId returned by createTicket. Each ticket // type is its own on-chain entry (one createTicket call per type // when the event publishes). Null until the publish job confirms. - onChainTicketId BigInt? @unique @map("on_chain_ticket_id") + // Unique per event, NOT globally: each chain's Diamond issues ids from + // its own counter starting at 1, so the same numeric id legitimately + // recurs across chains. An event is bound to a single chain, so + // (eventId, onChainTicketId) is the correct uniqueness scope. + onChainTicketId BigInt? @map("on_chain_ticket_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") event Event @relation(fields: [eventId], references: [id]) tickets Ticket[] + @@unique([eventId, onChainTicketId]) @@index([eventId]) @@map("ticket_types") } diff --git a/src/blockchain/blockchain-read.service.ts b/src/blockchain/blockchain-read.service.ts index 65c4dc6..4d8d083 100644 --- a/src/blockchain/blockchain-read.service.ts +++ b/src/blockchain/blockchain-read.service.ts @@ -130,6 +130,34 @@ export class BlockchainReadService { ) as Promise; } + /** Organizer's claimable/escrow balance for a ticket, in fee-token base units. */ + async getTicketBalance( + chain: string, + ticketId: bigint, + feeType: number, + ): Promise { + return this.getDiamond(chain).getTicketBalance( + ticketId, + feeType, + ) as Promise; + } + + /** Addresses checked in for a ticket (all days). Count = length. */ + async getCheckedIn(chain: string, ticketId: bigint): Promise { + const r = await this.getDiamond(chain).getCheckedIn(ticketId); + return Array.from(r as Iterable); + } + + /** Addresses checked in for a ticket on a specific event day (0-based). */ + async getCheckedInForDay( + chain: string, + ticketId: bigint, + day: number, + ): Promise { + const r = await this.getDiamond(chain).getCheckedInForDay(ticketId, day); + return Array.from(r as Iterable); + } + async isCheckedIn( chain: string, ticketId: bigint, diff --git a/src/organizer/dto/ticket-admins.dto.ts b/src/organizer/dto/ticket-admins.dto.ts new file mode 100644 index 0000000..59a04e6 --- /dev/null +++ b/src/organizer/dto/ticket-admins.dto.ts @@ -0,0 +1,14 @@ +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +/** + * Users to grant/revoke as on-chain ticket admins (check-in delegates) + * for an event. Each id is a HostIT user whose wallet on the event's + * chain receives (or loses) the ticketAdmin role. + */ +export class TicketAdminsDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(20) + @IsUUID('4', { each: true }) + userIds: string[]; +} diff --git a/src/organizer/dto/update-ticket-fee.dto.ts b/src/organizer/dto/update-ticket-fee.dto.ts new file mode 100644 index 0000000..ebe76cd --- /dev/null +++ b/src/organizer/dto/update-ticket-fee.dto.ts @@ -0,0 +1,13 @@ +import { IsNumber, Max, Min } from 'class-validator'; + +/** + * New face price (NGN) for a ticket type. Converted to the on-chain USDC + * fee (6-dp) the same way publish does. Paid-event bounds mirror event + * creation (>= 500, <= 500000). + */ +export class UpdateTicketFeeDto { + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(500) + @Max(500000) + priceNgn: number; +} diff --git a/src/organizer/onchain-reads.service.spec.ts b/src/organizer/onchain-reads.service.spec.ts new file mode 100644 index 0000000..a6f05f4 --- /dev/null +++ b/src/organizer/onchain-reads.service.spec.ts @@ -0,0 +1,128 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { OnchainReadsService } from './onchain-reads.service'; + +const ORG = 'org-1'; +const EVENT = 'evt-1'; +const CHAIN = 'BASE-SEPOLIA'; + +function setup(opts: { + ownerId?: string; + ticketTypes?: { id: string; name: string; onChainTicketId: bigint | null }[]; + read?: Partial<{ + getTicketBalance: jest.Mock; + getCheckedIn: jest.Mock; + getCheckedInForDay: jest.Mock; + }>; +}) { + const { + ownerId = ORG, + ticketTypes = [ + { id: 'tt-1', name: 'GA', onChainTicketId: 1n }, + { id: 'tt-2', name: 'VIP', onChainTicketId: 2n }, + ], + read = {}, + } = opts; + + const prisma = { + event: { + findUnique: jest.fn(() => ({ + organizerId: ownerId, + chain: CHAIN, + ticketTypes, + })), + }, + }; + const readSvc = { + getTicketBalance: read.getTicketBalance ?? jest.fn(() => 1500000n), + getCheckedIn: read.getCheckedIn ?? jest.fn(() => ['0xa', '0xb', '0xc']), + getCheckedInForDay: read.getCheckedInForDay ?? jest.fn(() => ['0xa']), + }; + const svc = new OnchainReadsService(prisma as any, readSvc as any); + return { svc, prisma, readSvc }; +} + +describe('OnchainReadsService (#103)', () => { + it('returns per-ticket USDC balance formatted 6-dp', async () => { + const { svc } = setup({}); + const res = await svc.getBalances(ORG, EVENT); + expect(res.chain).toBe(CHAIN); + expect(res.tickets).toHaveLength(2); + expect(res.tickets[0]).toMatchObject({ + ticketTypeId: 'tt-1', + balanceUsdc: '1.500000', + balanceRaw: '1500000', + }); + }); + + it('sums check-in totals across ticket types', async () => { + const { svc } = setup({ + read: { + getCheckedIn: jest + .fn() + .mockResolvedValueOnce(['0xa', '0xb']) + .mockResolvedValueOnce(['0xc']), + }, + }); + const res = await svc.getCheckins(ORG, EVENT); + expect(res.total).toBe(3); + expect(res.tickets[0].checkedIn).toBe(2); + expect(res.tickets[1].checkedIn).toBe(1); + }); + + it('is resilient: a failing RPC read becomes an error entry, not a 500', async () => { + const { svc } = setup({ + read: { + getTicketBalance: jest + .fn() + .mockResolvedValueOnce(2000000n) + .mockRejectedValueOnce(new Error('rpc down')), + }, + }); + const res = await svc.getBalances(ORG, EVENT); + expect(res.tickets[0]).toMatchObject({ balanceUsdc: '2.000000' }); + expect(res.tickets[1]).toMatchObject({ error: 'unavailable' }); + }); + + it('skips ticket types not yet on-chain', async () => { + const { svc, readSvc } = setup({ + ticketTypes: [ + { id: 'tt-1', name: 'GA', onChainTicketId: 1n }, + { id: 'tt-2', name: 'VIP', onChainTicketId: null }, + ], + }); + const res = await svc.getCheckins(ORG, EVENT); + expect(res.tickets).toHaveLength(1); + expect(readSvc.getCheckedIn).toHaveBeenCalledTimes(1); + }); + + it('rejects a non-owner with 403', async () => { + const { svc } = setup({ ownerId: 'other' }); + await expect(svc.getBalances(ORG, EVENT)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('validates the day param', async () => { + const { svc } = setup({}); + await expect(svc.getCheckinsForDay(ORG, EVENT, -1)).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(svc.getCheckinsForDay(ORG, EVENT, 300)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('returns per-day check-in counts', async () => { + const { svc } = setup({ + read: { + getCheckedInForDay: jest + .fn() + .mockResolvedValueOnce(['0xa', '0xb']) + .mockResolvedValueOnce([]), + }, + }); + const res = await svc.getCheckinsForDay(ORG, EVENT, 0); + expect(res.day).toBe(0); + expect(res.total).toBe(2); + }); +}); diff --git a/src/organizer/onchain-reads.service.ts b/src/organizer/onchain-reads.service.ts new file mode 100644 index 0000000..3dd4834 --- /dev/null +++ b/src/organizer/onchain-reads.service.ts @@ -0,0 +1,208 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { BlockchainReadService } from '../blockchain/blockchain-read.service'; +import { + FEE_TYPE_USDC, + SETTLEMENT_FEE_TYPE_NAME, + USDC_DECIMALS, +} from '../blockchain/onchain-fees'; + +/** Format a 6-dp USDC base-unit amount as a decimal string. */ +function formatUsdc(raw: bigint): string { + const neg = raw < 0n; + const s = (neg ? -raw : raw).toString().padStart(USDC_DECIMALS + 1, '0'); + const whole = s.slice(0, s.length - USDC_DECIMALS); + const frac = s.slice(s.length - USDC_DECIMALS); + return `${neg ? '-' : ''}${whole}.${frac}`; +} + +type OnchainTicketType = { + id: string; + name: string; + onChainTicketId: bigint; +}; + +/** + * Organizer dashboard reads straight from the deployed Diamond — live + * settlement balances and check-in counts. Pure view calls (no signing, + * no gas). Per-ticket reads are resilient: one failing RPC call surfaces + * as an `error` entry rather than failing the whole response. + */ +@Injectable() +export class OnchainReadsService { + private readonly logger = new Logger(OnchainReadsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly read: BlockchainReadService, + ) {} + + /** Organizer's claimable/escrow USDC per ticket type. */ + async getBalances(organizerId: string, eventId: string) { + const { chain, ticketTypes } = await this.loadOnchainTicketTypes( + eventId, + organizerId, + ); + + const tickets = await Promise.all( + ticketTypes.map(async (t) => { + try { + const raw = await this.read.getTicketBalance( + chain, + t.onChainTicketId, + FEE_TYPE_USDC, + ); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + balanceUsdc: formatUsdc(raw), + balanceRaw: raw.toString(), + }; + } catch (err) { + this.logger.warn( + `getTicketBalance failed (event=${eventId}, ticketType=${t.id}): ${ + (err as Error).message + }`, + ); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + error: 'unavailable', + }; + } + }), + ); + + return { chain, feeType: SETTLEMENT_FEE_TYPE_NAME, tickets }; + } + + /** On-chain check-in totals per ticket type + event total. */ + async getCheckins(organizerId: string, eventId: string) { + const { chain, ticketTypes } = await this.loadOnchainTicketTypes( + eventId, + organizerId, + ); + + const tickets = await Promise.all( + ticketTypes.map(async (t) => { + try { + const addrs = await this.read.getCheckedIn(chain, t.onChainTicketId); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + checkedIn: addrs.length, + }; + } catch (err) { + this.logger.warn( + `getCheckedIn failed (event=${eventId}, ticketType=${t.id}): ${ + (err as Error).message + }`, + ); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + error: 'unavailable', + }; + } + }), + ); + + const total = tickets.reduce( + (sum, t) => sum + (typeof t.checkedIn === 'number' ? t.checkedIn : 0), + 0, + ); + return { chain, total, tickets }; + } + + /** Per-ticket-type check-in counts for a specific event day (0-based). */ + async getCheckinsForDay(organizerId: string, eventId: string, day: number) { + if (!Number.isInteger(day) || day < 0 || day > 255) { + throw new BadRequestException('day must be an integer between 0 and 255'); + } + const { chain, ticketTypes } = await this.loadOnchainTicketTypes( + eventId, + organizerId, + ); + + const tickets = await Promise.all( + ticketTypes.map(async (t) => { + try { + const addrs = await this.read.getCheckedInForDay( + chain, + t.onChainTicketId, + day, + ); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + checkedIn: addrs.length, + }; + } catch (err) { + this.logger.warn( + `getCheckedInForDay failed (event=${eventId}, ticketType=${t.id}, day=${day}): ${ + (err as Error).message + }`, + ); + return { + ticketTypeId: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId.toString(), + error: 'unavailable', + }; + } + }), + ); + + const total = tickets.reduce( + (sum, t) => sum + (typeof t.checkedIn === 'number' ? t.checkedIn : 0), + 0, + ); + return { chain, day, total, tickets }; + } + + // ---------- internals ---------- + + private async loadOnchainTicketTypes( + eventId: string, + organizerId: string, + ): Promise<{ chain: string; ticketTypes: OnchainTicketType[] }> { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + select: { + organizerId: true, + chain: true, + ticketTypes: { + select: { id: true, name: true, onChainTicketId: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + if (event.organizerId !== organizerId) { + throw new ForbiddenException('You do not own this event'); + } + + const ticketTypes = event.ticketTypes + .filter((t): t is OnchainTicketType => t.onChainTicketId !== null) + .map((t) => ({ + id: t.id, + name: t.name, + onChainTicketId: t.onChainTicketId, + })); + + return { chain: event.chain, ticketTypes }; + } +} diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index fb247c8..fdac5fd 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -1,10 +1,13 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, Param, + ParseIntPipe, + Patch, Post, Put, Query, @@ -21,7 +24,12 @@ import { EnablePaystackDto } from './dto/enable-paystack.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { QueryAttendeesDto } from './dto/query-attendees.dto'; import { UpdateBankDetailsDto } from './dto/update-bank-details.dto'; +import { TicketAdminsDto } from './dto/ticket-admins.dto'; +import { UpdateTicketFeeDto } from './dto/update-ticket-fee.dto'; import { OrganizerService } from './organizer.service'; +import { TicketAdminsService } from './ticket-admins.service'; +import { TicketFeesService } from './ticket-fees.service'; +import { OnchainReadsService } from './onchain-reads.service'; /** * Per-provider fiat enablement endpoints. @@ -35,7 +43,12 @@ import { OrganizerService } from './organizer.service'; @ApiBearerAuth() @Controller('organizer') export class OrganizerController { - constructor(private readonly organizer: OrganizerService) {} + constructor( + private readonly organizer: OrganizerService, + private readonly ticketAdmins: TicketAdminsService, + private readonly ticketFees: TicketFeesService, + private readonly onchainReads: OnchainReadsService, + ) {} @Get('events') @Roles(UserRole.ORGANIZER) @@ -105,6 +118,89 @@ export class OrganizerController { return this.organizer.updateBankDetails(userId, dto); } + @Get('events/:id/ticket-admins') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ summary: 'List active check-in delegates (ticket admins)' }) + listTicketAdmins(@Param('id') id: string, @CurrentUser('id') userId: string) { + return this.ticketAdmins.listAdmins(userId, id); + } + + @Post('events/:id/ticket-admins') + @Roles(UserRole.ORGANIZER) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Delegate check-in: grant ticket-admin to users', + }) + addTicketAdmins( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Body() dto: TicketAdminsDto, + ) { + return this.ticketAdmins.addAdmins(userId, id, dto.userIds); + } + + @Delete('events/:id/ticket-admins') + @Roles(UserRole.ORGANIZER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Revoke ticket-admin (check-in) from users' }) + removeTicketAdmins( + @Param('id') id: string, + @CurrentUser('id') userId: string, + @Body() dto: TicketAdminsDto, + ) { + return this.ticketAdmins.removeAdmins(userId, id, dto.userIds); + } + + @Patch('events/:id/tickets/:ticketTypeId/fee') + @Roles(UserRole.ORGANIZER) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Update a ticket price (on-chain fee) after publish', + }) + updateTicketFee( + @Param('id') id: string, + @Param('ticketTypeId') ticketTypeId: string, + @CurrentUser('id') userId: string, + @Body() dto: UpdateTicketFeeDto, + ) { + return this.ticketFees.updateFee(userId, id, ticketTypeId, dto.priceNgn); + } + + @Get('events/:id/onchain/balance') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ + summary: 'On-chain claimable/escrow USDC balance per ticket type', + }) + getOnchainBalance( + @Param('id') id: string, + @CurrentUser('id') userId: string, + ) { + return this.onchainReads.getBalances(userId, id); + } + + @Get('events/:id/onchain/checkins') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ summary: 'On-chain check-in totals per ticket type' }) + getOnchainCheckins( + @Param('id') id: string, + @CurrentUser('id') userId: string, + ) { + return this.onchainReads.getCheckins(userId, id); + } + + @Get('events/:id/onchain/checkins/day/:day') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ + summary: 'On-chain check-in counts for a specific event day (0-based)', + }) + getOnchainCheckinsForDay( + @Param('id') id: string, + @Param('day', ParseIntPipe) day: number, + @CurrentUser('id') userId: string, + ) { + return this.onchainReads.getCheckinsForDay(userId, id, day); + } + @Post('providers/paystack/enable') @Roles(UserRole.ORGANIZER) @HttpCode(HttpStatus.OK) diff --git a/src/organizer/organizer.module.ts b/src/organizer/organizer.module.ts index cc8f601..7ef347a 100644 --- a/src/organizer/organizer.module.ts +++ b/src/organizer/organizer.module.ts @@ -1,8 +1,13 @@ import { Module } from '@nestjs/common'; import { PaymentsModule } from '../payments/payments.module'; import { PaystackModule } from '../paystack/paystack.module'; +import { BlockchainModule } from '../blockchain/blockchain.module'; +import { WalletsModule } from '../wallets/wallets.module'; import { OrganizerController } from './organizer.controller'; import { OrganizerService } from './organizer.service'; +import { TicketAdminsService } from './ticket-admins.service'; +import { TicketFeesService } from './ticket-fees.service'; +import { OnchainReadsService } from './onchain-reads.service'; /** * Organizer-side post-onboarding flows. The role flip itself stays in @@ -11,9 +16,14 @@ import { OrganizerService } from './organizer.service'; * enablement, with Stripe/Flutterwave/etc. landing here when added. */ @Module({ - imports: [PaystackModule, PaymentsModule], + imports: [PaystackModule, PaymentsModule, BlockchainModule, WalletsModule], controllers: [OrganizerController], - providers: [OrganizerService], + providers: [ + OrganizerService, + TicketAdminsService, + TicketFeesService, + OnchainReadsService, + ], exports: [OrganizerService], }) export class OrganizerModule {} diff --git a/src/organizer/ticket-admins.service.spec.ts b/src/organizer/ticket-admins.service.spec.ts new file mode 100644 index 0000000..ea14854 --- /dev/null +++ b/src/organizer/ticket-admins.service.spec.ts @@ -0,0 +1,170 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { BlockchainTxType, TicketAdminStatus, UserRole } from '@prisma/client'; +import { TicketAdminsService } from './ticket-admins.service'; + +const ORG = 'org-1'; +const EVENT = 'evt-1'; +const CHAIN = 'BASE-SEPOLIA'; + +type WalletRow = { address?: string; circleWalletId?: string } | null; + +function setup(opts: { + ownerId?: string; + onChainTicketIds?: (bigint | null)[]; + organizerWallet?: WalletRow; + users?: Record; + targetWallets?: Record; + activeAdmins?: { userId: string; address: string }[]; +}) { + const { + ownerId = ORG, + onChainTicketIds = [1n, 2n], + organizerWallet = { circleWalletId: 'org-wallet' }, + users = {}, + targetWallets = {}, + activeAdmins = [], + } = opts; + + const upsert = jest.fn((a: any) => a); + const updateMany = jest.fn(() => ({ count: activeAdmins.length })); + const findMany = jest.fn(() => + activeAdmins.map((a) => ({ + userId: a.userId, + address: a.address, + status: TicketAdminStatus.ACTIVE, + createdAt: new Date('2026-07-19T00:00:00Z'), + user: { + id: a.userId, + firstName: 'Del', + lastName: a.userId, + email: `${a.userId}@x.com`, + }, + })), + ); + + const prisma = { + event: { + findUnique: jest.fn(() => ({ + id: EVENT, + organizerId: ownerId, + chain: CHAIN, + ticketTypes: onChainTicketIds.map((onChainTicketId) => ({ + onChainTicketId, + })), + })), + }, + user: { + findUnique: jest.fn(({ where }: any) => { + const u = users[where.id]; + return u ? { id: where.id, role: u.role } : null; + }), + }, + userWallet: { + findFirst: jest.fn(({ where }: any) => { + if (where.userId === ORG) return organizerWallet; + return targetWallets[where.userId] ?? null; + }), + }, + eventTicketAdmin: { upsert, findMany, updateMany }, + $transaction: jest.fn((ops: Promise[]) => Promise.all(ops)), + }; + + const circle = { executeContract: jest.fn((_params: any) => ({})) }; + const wallets = { ensureWalletsForActiveChains: jest.fn(() => undefined) }; + + const svc = new TicketAdminsService( + prisma as any, + circle as any, + wallets as any, + ); + return { svc, prisma, circle, wallets, upsert, updateMany }; +} + +describe('TicketAdminsService (#101)', () => { + it('grants: one on-chain call per ticket type, records delegates ACTIVE', async () => { + const { svc, circle, upsert } = setup({ + users: { 'u-1': { role: UserRole.BUYER } }, + targetWallets: { 'u-1': { address: '0xabc' } }, + activeAdmins: [{ userId: 'u-1', address: '0xabc' }], + }); + + await svc.addAdmins(ORG, EVENT, ['u-1']); + + // 2 ticket types → 2 addTicketAdmins calls, each with the address list + expect(circle.executeContract).toHaveBeenCalledTimes(2); + const call = circle.executeContract.mock.calls[0][0]; + expect(call.method).toBe('addTicketAdmins'); + expect(call.args[1]).toEqual(['0xabc']); + expect(call.txType).toBe(BlockchainTxType.SET_ADMINS); + expect(call.walletId).toBe('org-wallet'); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it('rejects a non-owner with 403', async () => { + const { svc, circle } = setup({ ownerId: 'someone-else' }); + await expect(svc.addAdmins(ORG, EVENT, ['u-1'])).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(circle.executeContract).not.toHaveBeenCalled(); + }); + + it('kicks provisioning and rejects when target has no wallet', async () => { + const { svc, circle, wallets } = setup({ + users: { 'u-1': { role: UserRole.BUYER } }, + targetWallets: { 'u-1': null }, + }); + await expect(svc.addAdmins(ORG, EVENT, ['u-1'])).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(wallets.ensureWalletsForActiveChains).toHaveBeenCalledWith('u-1'); + expect(circle.executeContract).not.toHaveBeenCalled(); + }); + + it('rejects platform admins as targets', async () => { + const { svc } = setup({ + users: { 'u-1': { role: UserRole.ADMIN } }, + targetWallets: { 'u-1': { address: '0xabc' } }, + }); + await expect(svc.addAdmins(ORG, EVENT, ['u-1'])).rejects.toThrow( + /platform admin/, + ); + }); + + it('rejects when the event has no on-chain ticket types yet', async () => { + const { svc } = setup({ + onChainTicketIds: [null], + users: { 'u-1': { role: UserRole.BUYER } }, + targetWallets: { 'u-1': { address: '0xabc' } }, + }); + await expect(svc.addAdmins(ORG, EVENT, ['u-1'])).rejects.toThrow( + /no on-chain ticket types/, + ); + }); + + it('revokes: on-chain removeTicketAdmins per ticket type + marks REVOKED', async () => { + const { svc, circle, updateMany } = setup({ + activeAdmins: [{ userId: 'u-1', address: '0xabc' }], + }); + await svc.removeAdmins(ORG, EVENT, ['u-1']); + expect(circle.executeContract).toHaveBeenCalledTimes(2); + expect(circle.executeContract.mock.calls[0][0].method).toBe( + 'removeTicketAdmins', + ); + expect(updateMany).toHaveBeenCalled(); + }); + + it('revoke with no matching active admins → 400', async () => { + const { svc, circle } = setup({ activeAdmins: [] }); + await expect(svc.removeAdmins(ORG, EVENT, ['u-1'])).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(circle.executeContract).not.toHaveBeenCalled(); + }); + + it('rejects the organizer delegating to themselves', async () => { + const { svc } = setup({}); + await expect(svc.addAdmins(ORG, EVENT, [ORG])).rejects.toThrow( + /already the ticket admin/, + ); + }); +}); diff --git a/src/organizer/ticket-admins.service.ts b/src/organizer/ticket-admins.service.ts new file mode 100644 index 0000000..cc8e961 --- /dev/null +++ b/src/organizer/ticket-admins.service.ts @@ -0,0 +1,252 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + BlockchainTxType, + TicketAdminStatus, + UserRole, + WalletCreationStatus, +} from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CircleContractService } from '../blockchain/circle-contract.service'; +import { WalletsService } from '../wallets/wallets.service'; + +/** + * Organizer-facing check-in delegation. An organizer grants/revokes the + * on-chain `ticketAdminRole` to other HostIT users so they can scan + * attendees in. On-chain the role is per ticket type (the CheckInFacet's + * addTicketAdmins/removeTicketAdmins are keyed by the on-chain ticket id), + * so a single event-level grant fans out across all of the event's ticket + * types. The contract has no getter for the admin set, so the + * EventTicketAdmin table is the source of truth for listing delegates. + * + * The organizer signs the grant/revoke (they are the event's on-chain + * mainAdmin); the check-in worker already signs `checkIn` with the + * scanner's own wallet, so a granted delegate is picked up automatically. + */ +@Injectable() +export class TicketAdminsService { + private readonly logger = new Logger(TicketAdminsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly circle: CircleContractService, + private readonly wallets: WalletsService, + ) {} + + /** Grant ticket-admin (check-in) rights to one or more users. */ + async addAdmins(organizerId: string, eventId: string, userIds: string[]) { + const event = await this.loadOwnedEvent(eventId, organizerId); + const onchainTicketIds = this.resolveOnchainTicketIds(event.ticketTypes); + const signerWalletId = await this.resolveOrganizerSigner( + organizerId, + event.chain, + ); + + const targets: { userId: string; address: string }[] = []; + const provisioning: string[] = []; + + for (const userId of [...new Set(userIds)]) { + if (userId === organizerId) { + throw new BadRequestException( + 'You are already the ticket admin for this event', + ); + } + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true }, + }); + if (!user) { + throw new BadRequestException(`User ${userId} not found`); + } + if (user.role === UserRole.ADMIN) { + throw new BadRequestException( + `User ${userId} is a platform admin and cannot be a ticket admin`, + ); + } + + const wallet = await this.prisma.userWallet.findFirst({ + where: { + userId, + chain: event.chain, + creationStatus: WalletCreationStatus.CREATED, + }, + select: { address: true }, + }); + if (!wallet?.address) { + // Kick provisioning so a retry succeeds, then reject clearly. + await this.wallets.ensureWalletsForActiveChains(userId); + provisioning.push(userId); + continue; + } + targets.push({ userId, address: wallet.address }); + } + + if (provisioning.length > 0) { + throw new BadRequestException( + `Wallet still provisioning on ${event.chain} for: ${provisioning.join( + ', ', + )}. It was just kicked off — retry shortly.`, + ); + } + + const addresses = targets.map((t) => t.address); + // One grant per ticket type (role is keyed by on-chain ticket id). + for (const ticketId of onchainTicketIds) { + await this.circle.executeContract({ + method: 'addTicketAdmins', + args: [ticketId, addresses], + chain: event.chain, + txType: BlockchainTxType.SET_ADMINS, + eventId, + walletId: signerWalletId, + }); + } + + await this.prisma.$transaction( + targets.map((t) => + this.prisma.eventTicketAdmin.upsert({ + where: { eventId_userId: { eventId, userId: t.userId } }, + create: { + eventId, + userId: t.userId, + address: t.address, + status: TicketAdminStatus.ACTIVE, + }, + update: { address: t.address, status: TicketAdminStatus.ACTIVE }, + }), + ), + ); + + this.logger.log( + `Granted ticket-admin to ${targets.length} user(s) on event ${eventId} ` + + `across ${onchainTicketIds.length} ticket type(s)`, + ); + + return this.listAdmins(organizerId, eventId); + } + + /** Revoke ticket-admin rights from one or more users. */ + async removeAdmins(organizerId: string, eventId: string, userIds: string[]) { + const event = await this.loadOwnedEvent(eventId, organizerId); + const onchainTicketIds = this.resolveOnchainTicketIds(event.ticketTypes); + const signerWalletId = await this.resolveOrganizerSigner( + organizerId, + event.chain, + ); + + const admins = await this.prisma.eventTicketAdmin.findMany({ + where: { + eventId, + userId: { in: [...new Set(userIds)] }, + status: TicketAdminStatus.ACTIVE, + }, + }); + if (admins.length === 0) { + throw new BadRequestException( + 'None of the given users are active ticket admins for this event', + ); + } + + const addresses = admins.map((a) => a.address); + for (const ticketId of onchainTicketIds) { + await this.circle.executeContract({ + method: 'removeTicketAdmins', + args: [ticketId, addresses], + chain: event.chain, + txType: BlockchainTxType.SET_ADMINS, + eventId, + walletId: signerWalletId, + }); + } + + await this.prisma.eventTicketAdmin.updateMany({ + where: { eventId, userId: { in: admins.map((a) => a.userId) } }, + data: { status: TicketAdminStatus.REVOKED }, + }); + + this.logger.log( + `Revoked ticket-admin from ${admins.length} user(s) on event ${eventId}`, + ); + + return this.listAdmins(organizerId, eventId); + } + + /** List the event's current (active) check-in delegates. */ + async listAdmins(organizerId: string, eventId: string) { + await this.loadOwnedEvent(eventId, organizerId); + + const admins = await this.prisma.eventTicketAdmin.findMany({ + where: { eventId, status: TicketAdminStatus.ACTIVE }, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return admins.map((a) => ({ + userId: a.userId, + name: `${a.user.firstName} ${a.user.lastName}`, + email: a.user.email, + address: a.address, + status: a.status, + grantedAt: a.createdAt, + })); + } + + // ---------- internals ---------- + + private async loadOwnedEvent(eventId: string, organizerId: string) { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + include: { ticketTypes: { select: { onChainTicketId: true } } }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + if (event.organizerId !== organizerId) { + throw new ForbiddenException('You do not own this event'); + } + return event; + } + + private resolveOnchainTicketIds( + ticketTypes: { onChainTicketId: bigint | null }[], + ): bigint[] { + const ids = ticketTypes + .map((t) => t.onChainTicketId) + .filter((id): id is bigint => id !== null); + if (ids.length === 0) { + throw new BadRequestException( + 'Event has no on-chain ticket types yet — publish must complete before delegating check-in', + ); + } + return ids; + } + + private async resolveOrganizerSigner( + organizerId: string, + chain: string, + ): Promise { + const wallet = await this.prisma.userWallet.findFirst({ + where: { + userId: organizerId, + chain, + creationStatus: WalletCreationStatus.CREATED, + }, + select: { circleWalletId: true }, + }); + if (!wallet?.circleWalletId) { + throw new BadRequestException( + `You have no ready wallet on ${chain} to sign the ticket-admin change`, + ); + } + return wallet.circleWalletId; + } +} diff --git a/src/organizer/ticket-fees.service.spec.ts b/src/organizer/ticket-fees.service.spec.ts new file mode 100644 index 0000000..c1f4335 --- /dev/null +++ b/src/organizer/ticket-fees.service.spec.ts @@ -0,0 +1,124 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { BlockchainTxType, EventStatus } from '@prisma/client'; +import { TicketFeesService } from './ticket-fees.service'; +import { FEE_TYPE_USDC } from '../blockchain/onchain-fees'; + +const ORG = 'org-1'; +const EVENT = 'evt-1'; +const TT = 'tt-1'; +const CHAIN = 'BASE-SEPOLIA'; +// startTime far in the future so the "event started" guard passes. +const FUTURE = new Date('2099-01-01T00:00:00Z'); + +function setup(opts: { + ownerId?: string; + isFree?: boolean; + status?: EventStatus; + startTime?: Date; + onChainTicketId?: bigint | null; + organizerWalletId?: string | null; +}) { + const { + ownerId = ORG, + isFree = false, + status = EventStatus.PUBLISHED, + startTime = FUTURE, + onChainTicketId = 7n, + organizerWalletId = 'org-wallet', + } = opts; + + const ticketTypeUpdate = jest.fn(() => ({ + id: TT, + name: 'VIP', + price: '2000', + })); + + const prisma = { + event: { + findUnique: jest.fn(() => ({ + organizerId: ownerId, + chain: CHAIN, + isFree, + status, + startTime, + })), + }, + ticketType: { + findFirst: jest.fn(() => ({ id: TT, name: 'VIP', onChainTicketId })), + update: ticketTypeUpdate, + }, + userWallet: { + findFirst: jest.fn(() => + organizerWalletId ? { circleWalletId: organizerWalletId } : null, + ), + }, + }; + const circle = { executeContract: jest.fn((_p: any) => ({})) }; + const config = { getOrThrow: jest.fn(() => 1600) }; + + const svc = new TicketFeesService( + prisma as any, + circle as any, + config as any, + ); + return { svc, prisma, circle, ticketTypeUpdate }; +} + +describe('TicketFeesService (#102)', () => { + it('submits organizer-signed updateTicketFees and updates the DB price', async () => { + const { svc, circle, ticketTypeUpdate } = setup({}); + await svc.updateFee(ORG, EVENT, TT, 2000); + + expect(circle.executeContract).toHaveBeenCalledTimes(1); + const call = circle.executeContract.mock.calls[0][0]; + expect(call.method).toBe('updateTicketFees'); + expect(call.args[0]).toBe(7n); // onChainTicketId + expect(call.args[1]).toEqual([FEE_TYPE_USDC]); + expect(typeof call.args[2][0]).toBe('bigint'); // fee in USDC base units + expect(call.txType).toBe(BlockchainTxType.SET_FEES); + expect(call.walletId).toBe('org-wallet'); + expect(ticketTypeUpdate).toHaveBeenCalled(); + }); + + it('rejects a non-owner with 403', async () => { + const { svc, circle } = setup({ ownerId: 'other' }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(circle.executeContract).not.toHaveBeenCalled(); + }); + + it('rejects free events', async () => { + const { svc } = setup({ isFree: true }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toThrow(/Free/); + }); + + it('rejects non-published events', async () => { + const { svc } = setup({ status: EventStatus.DRAFT }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toThrow( + /published/, + ); + }); + + it('rejects once the event has started', async () => { + const { svc } = setup({ startTime: new Date('2000-01-01T00:00:00Z') }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toThrow( + /after the event has started/, + ); + }); + + it('rejects a ticket type not yet on-chain', async () => { + const { svc, circle } = setup({ onChainTicketId: null }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toThrow( + /not on-chain yet/, + ); + expect(circle.executeContract).not.toHaveBeenCalled(); + }); + + it('rejects when organizer has no ready signing wallet', async () => { + const { svc } = setup({ organizerWalletId: null }); + await expect(svc.updateFee(ORG, EVENT, TT, 2000)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/organizer/ticket-fees.service.ts b/src/organizer/ticket-fees.service.ts new file mode 100644 index 0000000..58ae85e --- /dev/null +++ b/src/organizer/ticket-fees.service.ts @@ -0,0 +1,147 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + BlockchainTxType, + EventStatus, + Prisma, + WalletCreationStatus, +} from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CircleContractService } from '../blockchain/circle-contract.service'; +import { computeUsdcFees, FEE_TYPE_USDC } from '../blockchain/onchain-fees'; + +/** + * Organizer-facing ticket price updates. A published event's ticket price + * is set on-chain once at publish (NGN → USDC 6-dp ticketFee). This lets + * the organizer change it afterwards via organizer-signed + * `updateTicketFees`, re-deriving the on-chain fee the same way publish + * does. Only future mints are affected — already-minted tickets keep the + * price they were bought at. + */ +@Injectable() +export class TicketFeesService { + private readonly logger = new Logger(TicketFeesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly circle: CircleContractService, + private readonly config: ConfigService, + ) {} + + async updateFee( + organizerId: string, + eventId: string, + ticketTypeId: string, + priceNgn: number, + ) { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + select: { + organizerId: true, + chain: true, + isFree: true, + status: true, + startTime: true, + }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + if (event.organizerId !== organizerId) { + throw new ForbiddenException('You do not own this event'); + } + if (event.isFree) { + throw new BadRequestException( + 'Free events have no on-chain fee to update', + ); + } + if (event.status !== EventStatus.PUBLISHED) { + throw new BadRequestException( + 'Only published events have an on-chain fee — edit the draft price directly', + ); + } + if (Date.now() >= event.startTime.getTime()) { + throw new BadRequestException( + 'Cannot change ticket price after the event has started', + ); + } + + const ticketType = await this.prisma.ticketType.findFirst({ + where: { id: ticketTypeId, eventId }, + select: { id: true, name: true, onChainTicketId: true }, + }); + if (!ticketType) { + throw new NotFoundException('Ticket type not found for this event'); + } + if (ticketType.onChainTicketId === null) { + throw new BadRequestException( + 'Ticket type is not on-chain yet — publish must complete first', + ); + } + + const signerWalletId = await this.resolveOrganizerSigner( + organizerId, + event.chain, + ); + + const usdcNgnRate = this.config.getOrThrow('crypto.usdcNgnRate'); + const { ticketFee } = computeUsdcFees(priceNgn, usdcNgnRate); + + // Organizer-signed on-chain fee update. Submitted async (webhook + // reconciles the BlockchainTransaction). Only the DB price is updated + // after a successful submit; a failed submit leaves both untouched. + await this.circle.executeContract({ + method: 'updateTicketFees', + args: [ticketType.onChainTicketId, [FEE_TYPE_USDC], [BigInt(ticketFee)]], + chain: event.chain, + txType: BlockchainTxType.SET_FEES, + eventId, + walletId: signerWalletId, + }); + + const updated = await this.prisma.ticketType.update({ + where: { id: ticketTypeId }, + data: { price: new Prisma.Decimal(priceNgn) }, + select: { id: true, name: true, price: true }, + }); + + this.logger.log( + `Updated fee for ticketType ${ticketTypeId} (event ${eventId}) → ` + + `${priceNgn} NGN / ${ticketFee} USDC base units`, + ); + + return { + ticketTypeId: updated.id, + name: updated.name, + priceNgn: updated.price, + onChainTicketId: ticketType.onChainTicketId.toString(), + onChainFeeUsdc: ticketFee, + }; + } + + private async resolveOrganizerSigner( + organizerId: string, + chain: string, + ): Promise { + const wallet = await this.prisma.userWallet.findFirst({ + where: { + userId: organizerId, + chain, + creationStatus: WalletCreationStatus.CREATED, + }, + select: { circleWalletId: true }, + }); + if (!wallet?.circleWalletId) { + throw new BadRequestException( + `You have no ready wallet on ${chain} to sign the fee update`, + ); + } + return wallet.circleWalletId; + } +} diff --git a/src/wallets/wallets.service.ts b/src/wallets/wallets.service.ts index 94f2aeb..b63c59e 100644 --- a/src/wallets/wallets.service.ts +++ b/src/wallets/wallets.service.ts @@ -5,10 +5,7 @@ import { randomUUID } from 'node:crypto'; import { ConfigService } from '@nestjs/config'; import { UserRole, WalletCreationStatus, WalletType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { - getDefaultChain, - listActiveChains, -} from '../blockchain/chains.config'; +import { getDefaultChain, listActiveChains } from '../blockchain/chains.config'; import { USER_WALLET_JOB, USER_WALLET_QUEUE,