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
21 changes: 21 additions & 0 deletions src/organizer/dto/lookup-user.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions src/organizer/organizer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)' })
Expand Down
74 changes: 74 additions & 0 deletions src/organizer/organizer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
58 changes: 58 additions & 0 deletions src/organizer/organizer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading