diff --git a/src/organizer/dto/lookup-user.dto.ts b/src/organizer/dto/lookup-user.dto.ts new file mode 100644 index 0000000..81b19e5 --- /dev/null +++ b/src/organizer/dto/lookup-user.dto.ts @@ -0,0 +1,21 @@ +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; + +/** + * Query for `GET /organizer/users/lookup`, used by the mobile Check-in + * team screen to resolve a teammate before granting ticket-admin. + * + * Provide exactly one of: + * - `email` — exact, case-insensitive match → a single user (or 404). + * - `q` — prefix/substring across email + name → up to 10 users, for + * type-ahead. Min length guards against broad enumeration. + */ +export class LookupUserDto { + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MinLength(2) + q?: string; +} diff --git a/src/organizer/organizer.controller.ts b/src/organizer/organizer.controller.ts index ce71182..9a0c58c 100644 --- a/src/organizer/organizer.controller.ts +++ b/src/organizer/organizer.controller.ts @@ -21,6 +21,7 @@ import { Roles } from '../common/decorators/roles.decorator'; import { SkipTransform } from '../common/decorators/skip-transform.decorator'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; +import { LookupUserDto } from './dto/lookup-user.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { QueryAttendeesDto } from './dto/query-attendees.dto'; import { QueryPayoutsDto } from './dto/query-payouts.dto'; @@ -122,6 +123,15 @@ export class OrganizerController { return this.organizer.updateBankDetails(userId, dto); } + @Get('users/lookup') + @Roles(UserRole.ORGANIZER) + @ApiOperation({ + summary: 'Resolve a teammate by email (or prefix) for the check-in team', + }) + lookupUsers(@Query() query: LookupUserDto) { + return this.organizer.lookupUsers(query); + } + @Get('events/:id/ticket-admins') @Roles(UserRole.ORGANIZER) @ApiOperation({ summary: 'List active check-in delegates (ticket admins)' }) diff --git a/src/organizer/organizer.service.spec.ts b/src/organizer/organizer.service.spec.ts index 4d12e6c..4e4f070 100644 --- a/src/organizer/organizer.service.spec.ts +++ b/src/organizer/organizer.service.spec.ts @@ -700,3 +700,77 @@ describe('OrganizerService.updateBankDetails', () => { expect(res.bankName).toBe('Old Bank'); }); }); + +describe('OrganizerService.lookupUsers', () => { + function lookupSvc(opts: { found?: unknown; list?: unknown[] } = {}) { + const findFirst = jest.fn().mockResolvedValue( + opts.found === undefined + ? { + id: 'u-1', + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@x.com', + } + : opts.found, + ); + const findMany = jest.fn().mockResolvedValue(opts.list ?? []); + const prisma = { + user: { findFirst, findMany }, + } as unknown as PrismaService; + + const svc = new OrganizerService( + prisma, + { get: () => undefined } as unknown as ConfigService, + {} as unknown as PaystackService, + {} as unknown as MonnifyProvider, + ); + return { svc, findFirst, findMany }; + } + + it('resolves an exact email (case-insensitive, trimmed) to id/name/email', async () => { + const { svc, findFirst } = lookupSvc(); + + const res = await svc.lookupUsers({ email: ' Jane@X.com ' }); + + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { email: { equals: 'Jane@X.com', mode: 'insensitive' } }, + }), + ); + expect(res).toEqual({ id: 'u-1', name: 'Jane Doe', email: 'jane@x.com' }); + }); + + it('404s when no user has that email', async () => { + const { svc } = lookupSvc({ found: null }); + await expect( + svc.lookupUsers({ email: 'nobody@x.com' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns a capped list for a prefix query', async () => { + const { svc, findMany } = lookupSvc({ + list: [ + { + id: 'u-2', + firstName: 'Jan', + lastName: 'Kowalski', + email: 'jan@x.com', + }, + ], + }); + + const res = await svc.lookupUsers({ q: 'jan' }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10 }), + ); + expect(res).toEqual([ + { id: 'u-2', name: 'Jan Kowalski', email: 'jan@x.com' }, + ]); + }); + + it('rejects when neither email nor q is provided', async () => { + const { svc } = lookupSvc(); + await expect(svc.lookupUsers({})).rejects.toThrow(/email or q/); + }); +}); diff --git a/src/organizer/organizer.service.ts b/src/organizer/organizer.service.ts index 403ccd1..7425ba3 100644 --- a/src/organizer/organizer.service.ts +++ b/src/organizer/organizer.service.ts @@ -20,6 +20,7 @@ import { PaystackService } from '../paystack/paystack.service'; import { PrismaService } from '../prisma/prisma.service'; import { EnableMonnifyDto } from './dto/enable-monnify.dto'; import { EnablePaystackDto } from './dto/enable-paystack.dto'; +import { LookupUserDto } from './dto/lookup-user.dto'; import { QueryOrganizerEventsDto } from './dto/query-organizer-events.dto'; import { QueryAttendeesDto } from './dto/query-attendees.dto'; import { UpdateBankDetailsDto } from './dto/update-bank-details.dto'; @@ -85,6 +86,63 @@ export class OrganizerService { private readonly monnify: MonnifyProvider, ) {} + /** + * Resolve a teammate for the Check-in team screen (#101). Feeds + * `POST /organizer/events/:id/ticket-admins`, which takes userIds — + * organizers only know a teammate's email. + * + * With `email`: exact, case-insensitive, trimmed match → the single + * user (404 if none). With `q`: a small prefix/substring list across + * email + name for type-ahead. Returns only id/name/email — no + * wallets, phone, or role. + */ + async lookupUsers(dto: LookupUserDto) { + const email = dto.email?.trim(); + const q = dto.q?.trim(); + + if (email) { + const user = await this.prisma.user.findFirst({ + where: { email: { equals: email, mode: 'insensitive' } }, + select: { id: true, firstName: true, lastName: true, email: true }, + }); + if (!user) { + throw new NotFoundException('No HostIT account for that email'); + } + return this.toLookupDto(user); + } + + if (q) { + const users = await this.prisma.user.findMany({ + where: { + OR: [ + { email: { contains: q, mode: 'insensitive' } }, + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { id: true, firstName: true, lastName: true, email: true }, + orderBy: { email: 'asc' }, + take: 10, + }); + return users.map((u) => this.toLookupDto(u)); + } + + throw new BadRequestException('Provide an email or q query parameter'); + } + + private toLookupDto(u: { + id: string; + firstName: string; + lastName: string; + email: string; + }) { + return { + id: u.id, + name: `${u.firstName} ${u.lastName}`.trim(), + email: u.email, + }; + } + /** * Organizer dashboard landing: the caller's events with per-event and * per-ticket-type sales stats, plus a top-level summary computed across