From 7a7f115308e0ede552ff92c9d58b88612dea6ff5 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 04:46:15 +0100 Subject: [PATCH 1/6] feat(organizer): ticket-admin check-in delegation (#101) Organizers can grant/revoke/list on-chain ticket admins so other HostIT users can scan attendees in. On-chain the ticketAdminRole is keyed by the per-type on-chain ticket id, so an event-level grant fans out across all of the event's ticket types via organizer-signed addTicketAdmins / removeTicketAdmins. The check-in worker already signs with the scanner's own wallet, so a granted delegate is picked up automatically. Delegates are tracked in a new EventTicketAdmin table (the contract has no getter for the admin set), which backs the list endpoint. Targets are resolved to their wallet on the event's chain; a missing wallet kicks provisioning and is rejected with a retry hint. Platform admins and self-delegation are rejected; non-owners get 403. Endpoints: GET/POST/DELETE /organizer/events/:id/ticket-admins. Closes #101 --- .../migration.sql | 33 +++ prisma/schema.prisma | 33 +++ src/organizer/dto/ticket-admins.dto.ts | 14 + src/organizer/organizer.controller.ts | 44 ++- src/organizer/organizer.module.ts | 7 +- src/organizer/ticket-admins.service.spec.ts | 175 ++++++++++++ src/organizer/ticket-admins.service.ts | 252 ++++++++++++++++++ 7 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260719034157_add_event_ticket_admins/migration.sql create mode 100644 src/organizer/dto/ticket-admins.dto.ts create mode 100644 src/organizer/ticket-admins.service.spec.ts create mode 100644 src/organizer/ticket-admins.service.ts 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/schema.prisma b/prisma/schema.prisma index c7108c5..d3aae2c 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 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/organizer.controller.ts b/src/organizer/organizer.controller.ts index fb247c8..215f54c 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, @@ -21,7 +22,9 @@ 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 { OrganizerService } from './organizer.service'; +import { TicketAdminsService } from './ticket-admins.service'; /** * Per-provider fiat enablement endpoints. @@ -35,7 +38,10 @@ 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, + ) {} @Get('events') @Roles(UserRole.ORGANIZER) @@ -105,6 +111,42 @@ 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); + } + @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..93b9dbf 100644 --- a/src/organizer/organizer.module.ts +++ b/src/organizer/organizer.module.ts @@ -1,8 +1,11 @@ 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'; /** * Organizer-side post-onboarding flows. The role flip itself stays in @@ -11,9 +14,9 @@ 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], 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..8392bfe --- /dev/null +++ b/src/organizer/ticket-admins.service.spec.ts @@ -0,0 +1,175 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + BlockchainTxType, + TicketAdminStatus, + UserRole, + WalletCreationStatus, +} 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(async (a: any) => a); + const updateMany = jest.fn(async () => ({ count: activeAdmins.length })); + const findMany = jest.fn(async () => + 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(async () => ({ + id: EVENT, + organizerId: ownerId, + chain: CHAIN, + ticketTypes: onChainTicketIds.map((onChainTicketId) => ({ + onChainTicketId, + })), + })), + }, + user: { + findUnique: jest.fn(async ({ where }: any) => { + const u = users[where.id]; + return u ? { id: where.id, role: u.role } : null; + }), + }, + userWallet: { + findFirst: jest.fn(async ({ 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(async () => ({})) }; + const wallets = { ensureWalletsForActiveChains: jest.fn(async () => 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; + } +} From 4fef15808439343cf5eb4710ace36379db363241 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 04:50:09 +0100 Subject: [PATCH 2/6] feat(organizer): update ticket price on-chain after publish (#102) Add PATCH /organizer/events/:id/tickets/:ticketTypeId/fee so an organizer can change a published ticket's price. The new NGN price is converted to the on-chain USDC 6-dp ticketFee exactly as publish does (computeUsdcFees) and written via organizer-signed updateTicketFees; the DB price is updated only after a successful submit. Only future mints are affected. Guardrails: owner-only (403 otherwise), published + paid events only, locked once the event has started, and the ticket type must already be on-chain. Bounds mirror event creation (500..500000 NGN). Closes #102 --- src/organizer/dto/update-ticket-fee.dto.ts | 13 ++ src/organizer/organizer.controller.ts | 19 +++ src/organizer/organizer.module.ts | 3 +- src/organizer/ticket-fees.service.spec.ts | 120 +++++++++++++++++ src/organizer/ticket-fees.service.ts | 146 +++++++++++++++++++++ 5 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 src/organizer/dto/update-ticket-fee.dto.ts create mode 100644 src/organizer/ticket-fees.service.spec.ts create mode 100644 src/organizer/ticket-fees.service.ts 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/organizer.controller.ts b/src/organizer/organizer.controller.ts index 215f54c..de81784 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -6,6 +6,7 @@ import { HttpCode, HttpStatus, Param, + Patch, Post, Put, Query, @@ -23,8 +24,10 @@ 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'; /** * Per-provider fiat enablement endpoints. @@ -41,6 +44,7 @@ export class OrganizerController { constructor( private readonly organizer: OrganizerService, private readonly ticketAdmins: TicketAdminsService, + private readonly ticketFees: TicketFeesService, ) {} @Get('events') @@ -147,6 +151,21 @@ export class OrganizerController { 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); + } + @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 93b9dbf..788b230 100644 --- a/src/organizer/organizer.module.ts +++ b/src/organizer/organizer.module.ts @@ -6,6 +6,7 @@ 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'; /** * Organizer-side post-onboarding flows. The role flip itself stays in @@ -16,7 +17,7 @@ import { TicketAdminsService } from './ticket-admins.service'; @Module({ imports: [PaystackModule, PaymentsModule, BlockchainModule, WalletsModule], controllers: [OrganizerController], - providers: [OrganizerService, TicketAdminsService], + providers: [OrganizerService, TicketAdminsService, TicketFeesService], exports: [OrganizerService], }) export class OrganizerModule {} diff --git a/src/organizer/ticket-fees.service.spec.ts b/src/organizer/ticket-fees.service.spec.ts new file mode 100644 index 0000000..8cd9f6b --- /dev/null +++ b/src/organizer/ticket-fees.service.spec.ts @@ -0,0 +1,120 @@ +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(async () => ({ + id: TT, + name: 'VIP', + price: '2000', + })); + + const prisma = { + event: { + findUnique: jest.fn(async () => ({ + organizerId: ownerId, + chain: CHAIN, + isFree, + status, + startTime, + })), + }, + ticketType: { + findFirst: jest.fn(async () => ({ id: TT, name: 'VIP', onChainTicketId })), + update: ticketTypeUpdate, + }, + userWallet: { + findFirst: jest.fn(async () => + organizerWalletId ? { circleWalletId: organizerWalletId } : null, + ), + }, + }; + const circle = { executeContract: jest.fn(async (_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..0c1387e --- /dev/null +++ b/src/organizer/ticket-fees.service.ts @@ -0,0 +1,146 @@ +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; + } +} From 90a0eb266228da08cfcf7c2f6ed75da36aacf356 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 04:50:23 +0100 Subject: [PATCH 3/6] test(organizer): type the executeContract mock param in ticket-admins spec Keeps tsc --noEmit green; the jest.fn had a no-arg signature so mock.calls indexing tripped noUncheckedIndexedAccess. --- src/organizer/ticket-admins.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/organizer/ticket-admins.service.spec.ts b/src/organizer/ticket-admins.service.spec.ts index 8392bfe..700edd9 100644 --- a/src/organizer/ticket-admins.service.spec.ts +++ b/src/organizer/ticket-admins.service.spec.ts @@ -74,7 +74,7 @@ function setup(opts: { $transaction: jest.fn((ops: Promise[]) => Promise.all(ops)), }; - const circle = { executeContract: jest.fn(async () => ({})) }; + const circle = { executeContract: jest.fn(async (_params: any) => ({})) }; const wallets = { ensureWalletsForActiveChains: jest.fn(async () => undefined) }; const svc = new TicketAdminsService( From fd219d7e12aee450b566d01711f6bb9a11628e7d Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 04:54:17 +0100 Subject: [PATCH 4/6] feat(organizer): on-chain settlement + check-in reads (#103) Add organizer dashboard reads straight from the Diamond (pure view calls, no signing): claimable/escrow USDC balance per ticket type (getTicketBalance), check-in totals per ticket type + event total (getCheckedIn), and per-day check-in counts (getCheckedInForDay). New BlockchainReadService wrappers for the three views; a resilient OnchainReadsService that resolves the event's on-chain ticket types and tolerates RPC hiccups per-ticket (a failed read becomes an `error` entry rather than failing the whole response). Owner-only (403 otherwise). Endpoints: GET /organizer/events/:id/onchain/{balance,checkins, checkins/day/:day}. Closes #103 --- src/blockchain/blockchain-read.service.ts | 28 +++ src/organizer/onchain-reads.service.spec.ts | 129 ++++++++++++ src/organizer/onchain-reads.service.ts | 208 ++++++++++++++++++++ src/organizer/organizer.controller.ts | 38 ++++ src/organizer/organizer.module.ts | 8 +- 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/organizer/onchain-reads.service.spec.ts create mode 100644 src/organizer/onchain-reads.service.ts 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/onchain-reads.service.spec.ts b/src/organizer/onchain-reads.service.spec.ts new file mode 100644 index 0000000..dca057a --- /dev/null +++ b/src/organizer/onchain-reads.service.spec.ts @@ -0,0 +1,129 @@ +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(async () => ({ + organizerId: ownerId, + chain: CHAIN, + ticketTypes, + })), + }, + }; + const readSvc = { + getTicketBalance: read.getTicketBalance ?? jest.fn(async () => 1500000n), + getCheckedIn: + read.getCheckedIn ?? jest.fn(async () => ['0xa', '0xb', '0xc']), + getCheckedInForDay: read.getCheckedInForDay ?? jest.fn(async () => ['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 de81784..9b4a959 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -6,6 +6,7 @@ import { HttpCode, HttpStatus, Param, + ParseIntPipe, Patch, Post, Put, @@ -28,6 +29,7 @@ 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. @@ -45,6 +47,7 @@ export class OrganizerController { private readonly organizer: OrganizerService, private readonly ticketAdmins: TicketAdminsService, private readonly ticketFees: TicketFeesService, + private readonly onchainReads: OnchainReadsService, ) {} @Get('events') @@ -166,6 +169,41 @@ export class OrganizerController { 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 788b230..7ef347a 100644 --- a/src/organizer/organizer.module.ts +++ b/src/organizer/organizer.module.ts @@ -7,6 +7,7 @@ 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 @@ -17,7 +18,12 @@ import { TicketFeesService } from './ticket-fees.service'; @Module({ imports: [PaystackModule, PaymentsModule, BlockchainModule, WalletsModule], controllers: [OrganizerController], - providers: [OrganizerService, TicketAdminsService, TicketFeesService], + providers: [ + OrganizerService, + TicketAdminsService, + TicketFeesService, + OnchainReadsService, + ], exports: [OrganizerService], }) export class OrganizerModule {} From 8a05617c2bb6e4b5be751aedb33b9e55250160aa Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 06:01:15 +0100 Subject: [PATCH 5/6] fix(events): scope on-chain ticket id uniqueness per event, not globally Each chain's Diamond issues ticket ids from its own counter starting at 1, so the same numeric id legitimately recurs across chains. The global @unique on TicketType.onChainTicketId made an Arc event's id collide with an existing Base Sepolia id, failing publish with a unique-constraint error and rolling the event back to DRAFT. An event is bound to a single chain, so (eventId, onChainTicketId) is the correct scope. No code depends on the field being globally unique. --- .../migration.sql | 9 +++++++++ prisma/schema.prisma | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260719051500_ticket_onchain_id_unique_per_event/migration.sql 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 d3aae2c..eac41f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -309,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") } From 37e8f0af71f2e7d7478e38b87f98a05f3aabcce1 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Sun, 19 Jul 2026 18:52:21 +0100 Subject: [PATCH 6/6] style: satisfy eslint (require-await, unused import, prettier) Strip async from value-returning jest mocks, drop an unused import, and apply prettier formatting the CI lint step flagged. No behavior change. --- src/organizer/onchain-reads.service.spec.ts | 9 ++++---- src/organizer/organizer.controller.ts | 5 +---- src/organizer/ticket-admins.service.spec.ts | 23 ++++++++------------- src/organizer/ticket-fees.service.spec.ts | 16 ++++++++------ src/organizer/ticket-fees.service.ts | 7 ++++--- src/wallets/wallets.service.ts | 5 +---- 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/organizer/onchain-reads.service.spec.ts b/src/organizer/onchain-reads.service.spec.ts index dca057a..a6f05f4 100644 --- a/src/organizer/onchain-reads.service.spec.ts +++ b/src/organizer/onchain-reads.service.spec.ts @@ -25,7 +25,7 @@ function setup(opts: { const prisma = { event: { - findUnique: jest.fn(async () => ({ + findUnique: jest.fn(() => ({ organizerId: ownerId, chain: CHAIN, ticketTypes, @@ -33,10 +33,9 @@ function setup(opts: { }, }; const readSvc = { - getTicketBalance: read.getTicketBalance ?? jest.fn(async () => 1500000n), - getCheckedIn: - read.getCheckedIn ?? jest.fn(async () => ['0xa', '0xb', '0xc']), - getCheckedInForDay: read.getCheckedInForDay ?? jest.fn(async () => ['0xa']), + 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 }; diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index 9b4a959..fdac5fd 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -121,10 +121,7 @@ export class OrganizerController { @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, - ) { + listTicketAdmins(@Param('id') id: string, @CurrentUser('id') userId: string) { return this.ticketAdmins.listAdmins(userId, id); } diff --git a/src/organizer/ticket-admins.service.spec.ts b/src/organizer/ticket-admins.service.spec.ts index 700edd9..ea14854 100644 --- a/src/organizer/ticket-admins.service.spec.ts +++ b/src/organizer/ticket-admins.service.spec.ts @@ -1,10 +1,5 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { - BlockchainTxType, - TicketAdminStatus, - UserRole, - WalletCreationStatus, -} from '@prisma/client'; +import { BlockchainTxType, TicketAdminStatus, UserRole } from '@prisma/client'; import { TicketAdminsService } from './ticket-admins.service'; const ORG = 'org-1'; @@ -30,9 +25,9 @@ function setup(opts: { activeAdmins = [], } = opts; - const upsert = jest.fn(async (a: any) => a); - const updateMany = jest.fn(async () => ({ count: activeAdmins.length })); - const findMany = jest.fn(async () => + 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, @@ -49,7 +44,7 @@ function setup(opts: { const prisma = { event: { - findUnique: jest.fn(async () => ({ + findUnique: jest.fn(() => ({ id: EVENT, organizerId: ownerId, chain: CHAIN, @@ -59,13 +54,13 @@ function setup(opts: { })), }, user: { - findUnique: jest.fn(async ({ where }: any) => { + findUnique: jest.fn(({ where }: any) => { const u = users[where.id]; return u ? { id: where.id, role: u.role } : null; }), }, userWallet: { - findFirst: jest.fn(async ({ where }: any) => { + findFirst: jest.fn(({ where }: any) => { if (where.userId === ORG) return organizerWallet; return targetWallets[where.userId] ?? null; }), @@ -74,8 +69,8 @@ function setup(opts: { $transaction: jest.fn((ops: Promise[]) => Promise.all(ops)), }; - const circle = { executeContract: jest.fn(async (_params: any) => ({})) }; - const wallets = { ensureWalletsForActiveChains: jest.fn(async () => undefined) }; + const circle = { executeContract: jest.fn((_params: any) => ({})) }; + const wallets = { ensureWalletsForActiveChains: jest.fn(() => undefined) }; const svc = new TicketAdminsService( prisma as any, diff --git a/src/organizer/ticket-fees.service.spec.ts b/src/organizer/ticket-fees.service.spec.ts index 8cd9f6b..c1f4335 100644 --- a/src/organizer/ticket-fees.service.spec.ts +++ b/src/organizer/ticket-fees.service.spec.ts @@ -27,7 +27,7 @@ function setup(opts: { organizerWalletId = 'org-wallet', } = opts; - const ticketTypeUpdate = jest.fn(async () => ({ + const ticketTypeUpdate = jest.fn(() => ({ id: TT, name: 'VIP', price: '2000', @@ -35,7 +35,7 @@ function setup(opts: { const prisma = { event: { - findUnique: jest.fn(async () => ({ + findUnique: jest.fn(() => ({ organizerId: ownerId, chain: CHAIN, isFree, @@ -44,19 +44,23 @@ function setup(opts: { })), }, ticketType: { - findFirst: jest.fn(async () => ({ id: TT, name: 'VIP', onChainTicketId })), + findFirst: jest.fn(() => ({ id: TT, name: 'VIP', onChainTicketId })), update: ticketTypeUpdate, }, userWallet: { - findFirst: jest.fn(async () => + findFirst: jest.fn(() => organizerWalletId ? { circleWalletId: organizerWalletId } : null, ), }, }; - const circle = { executeContract: jest.fn(async (_p: any) => ({})) }; + 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); + const svc = new TicketFeesService( + prisma as any, + circle as any, + config as any, + ); return { svc, prisma, circle, ticketTypeUpdate }; } diff --git a/src/organizer/ticket-fees.service.ts b/src/organizer/ticket-fees.service.ts index 0c1387e..58ae85e 100644 --- a/src/organizer/ticket-fees.service.ts +++ b/src/organizer/ticket-fees.service.ts @@ -57,7 +57,9 @@ export class TicketFeesService { throw new ForbiddenException('You do not own this event'); } if (event.isFree) { - throw new BadRequestException('Free events have no on-chain fee to update'); + throw new BadRequestException( + 'Free events have no on-chain fee to update', + ); } if (event.status !== EventStatus.PUBLISHED) { throw new BadRequestException( @@ -88,8 +90,7 @@ export class TicketFeesService { event.chain, ); - const usdcNgnRate = - this.config.getOrThrow('crypto.usdcNgnRate'); + const usdcNgnRate = this.config.getOrThrow('crypto.usdcNgnRate'); const { ticketFee } = computeUsdcFees(priceNgn, usdcNgnRate); // Organizer-signed on-chain fee update. Submitted async (webhook 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,