Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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");
40 changes: 39 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ enum BlockchainTxType {
CHECKIN
WITHDRAW
SET_FEES
SET_ADMINS
}

enum TicketAdminStatus {
ACTIVE
REVOKED
}

enum BlockchainTxStatus {
Expand Down Expand Up @@ -156,6 +162,7 @@ model User {
payouts Payout[]
notifications Notification[]
wallets UserWallet[]
ticketAdminFor EventTicketAdmin[]

@@map("users")
}
Expand Down Expand Up @@ -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
Expand All @@ -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")
}
Expand Down
28 changes: 28 additions & 0 deletions src/blockchain/blockchain-read.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,34 @@ export class BlockchainReadService {
) as Promise<string>;
}

/** Organizer's claimable/escrow balance for a ticket, in fee-token base units. */
async getTicketBalance(
chain: string,
ticketId: bigint,
feeType: number,
): Promise<bigint> {
return this.getDiamond(chain).getTicketBalance(
ticketId,
feeType,
) as Promise<bigint>;
}

/** Addresses checked in for a ticket (all days). Count = length. */
async getCheckedIn(chain: string, ticketId: bigint): Promise<string[]> {
const r = await this.getDiamond(chain).getCheckedIn(ticketId);
return Array.from(r as Iterable<string>);
}

/** Addresses checked in for a ticket on a specific event day (0-based). */
async getCheckedInForDay(
chain: string,
ticketId: bigint,
day: number,
): Promise<string[]> {
const r = await this.getDiamond(chain).getCheckedInForDay(ticketId, day);
return Array.from(r as Iterable<string>);
}

async isCheckedIn(
chain: string,
ticketId: bigint,
Expand Down
14 changes: 14 additions & 0 deletions src/organizer/dto/ticket-admins.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
13 changes: 13 additions & 0 deletions src/organizer/dto/update-ticket-fee.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
128 changes: 128 additions & 0 deletions src/organizer/onchain-reads.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading