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
64 changes: 64 additions & 0 deletions apps/api/src/app/human/dtos/list-contacts.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';

export const DEFAULT_CONTACTS_LIMIT = 50;
export const MAX_CONTACTS_LIMIT = 100;

export class ListContactsQueryDto {
@ApiPropertyOptional({ default: DEFAULT_CONTACTS_LIMIT, maximum: MAX_CONTACTS_LIMIT })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_CONTACTS_LIMIT)
limit?: number;

@ApiPropertyOptional({ description: 'Cursor from a previous page’s `next` — returns contacts after it.' })
@IsOptional()
@IsString()
after?: string;
}

/**
* A contact is a subscriber, viewed as "someone an agent can talk to".
* Only the human-facing subset of the subscriber is exposed — internal ids,
* legacy `channels`, and topic membership stay out of the contract.
*/
export class HumanContactDto {
@ApiProperty({ description: 'The subscriberId — pass it to `--to`.' })
id: string;

@ApiPropertyOptional()
firstName?: string;

@ApiPropertyOptional()
lastName?: string;

@ApiPropertyOptional()
email?: string;

@ApiPropertyOptional()
phone?: string;

@ApiPropertyOptional({
description: 'Free-form custom data on the subscriber (e.g. role notes).',
type: 'object',
additionalProperties: true,
})
data?: Record<string, unknown>;

@ApiProperty()
createdAt: string;

@ApiProperty()
updatedAt: string;
}

export class ListContactsResponseDto {
@ApiProperty({ type: [HumanContactDto] })
data: HumanContactDto[];

@ApiProperty({ nullable: true, description: 'Cursor for the next page, or null when this is the last page.' })
next: string | null;
}
14 changes: 14 additions & 0 deletions apps/api/src/app/human/dtos/setup-human-relay.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ export class SetupHumanRelayRequestDto {
@IsOptional()
@IsEmail()
email?: string;

@ApiPropertyOptional({
description: 'The human’s first name (display name shown to agents and in reply attribution).',
})
@IsOptional()
@IsString()
@MaxLength(128)
firstName?: string;

@ApiPropertyOptional({ description: 'The human’s last name.' })
@IsOptional()
@IsString()
@MaxLength(128)
lastName?: string;
}

export class SetupHumanRelayResponseDto {
Expand Down
116 changes: 116 additions & 0 deletions apps/api/src/app/human/e2e/human-contacts.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { SubscriberRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';

const subscriberRepository = new SubscriberRepository();

describe('Human contacts (setup names → list) #novu-v2', () => {
let session: UserSession;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
});

async function setup(body: Record<string, unknown>) {
const res = await session.testAgent.post('/v1/human/setup').send(body);
expect(res.status).to.equal(200, JSON.stringify(res.body));

return res.body.data as { subscriberId: string };
}

async function findSubscriber(subscriberId: string) {
return subscriberRepository.findOne({ subscriberId, _environmentId: session.environment._id });
}

describe('POST /v1/human/setup names', () => {
it('creates the subscriber with firstName and lastName', async () => {
const subscriberId = `contact-${Date.now()}`;
await setup({ subscriberId, firstName: 'Alice', lastName: 'Chen' });

const subscriber = await findSubscriber(subscriberId);
expect(subscriber?.firstName).to.equal('Alice');
expect(subscriber?.lastName).to.equal('Chen');
});

it('replaces the name on re-setup and keeps it when omitted', async () => {
const subscriberId = `contact-${Date.now()}`;
await setup({ subscriberId, firstName: 'Alice' });
await setup({ subscriberId, firstName: 'Alicia', lastName: 'Chen' });

let subscriber = await findSubscriber(subscriberId);
expect(subscriber?.firstName).to.equal('Alicia');
expect(subscriber?.lastName).to.equal('Chen');

await setup({ subscriberId });
subscriber = await findSubscriber(subscriberId);
expect(subscriber?.firstName).to.equal('Alicia');
expect(subscriber?.lastName).to.equal('Chen');
});
});

describe('GET /v1/human/contacts', () => {
it('lists every subscriber in the environment with only contact fields', async () => {
const stamp = Date.now();
await setup({ subscriberId: `alice-${stamp}`, firstName: 'Alice', lastName: 'Chen' });
await setup({ subscriberId: `bob-${stamp}`, email: 'bob@example.com' });
await subscriberRepository.create({
subscriberId: `carol-${stamp}`,
_environmentId: session.environment._id,
_organizationId: session.organization._id,
phone: '+15550000000',
data: { role: 'on-call' },
});

const res = await session.testAgent.get('/v1/human/contacts');
expect(res.status).to.equal(200, JSON.stringify(res.body));

const rows = res.body.data as Array<Record<string, unknown>>;
const byId = new Map(rows.map((row) => [row.id as string, row]));
expect(byId.has(`alice-${stamp}`)).to.equal(true);
expect(byId.has(`bob-${stamp}`)).to.equal(true);
expect(byId.has(`carol-${stamp}`)).to.equal(true);

const alice = byId.get(`alice-${stamp}`);
expect(alice?.firstName).to.equal('Alice');
expect(alice?.lastName).to.equal('Chen');
expect(byId.get(`bob-${stamp}`)?.email).to.equal('bob@example.com');

const carol = byId.get(`carol-${stamp}`);
expect(carol?.phone).to.equal('+15550000000');
expect(carol?.data).to.deep.equal({ role: 'on-call' });
expect(carol?.createdAt).to.be.a('string');
expect(carol?.updatedAt).to.be.a('string');

const allowedKeys = new Set(['id', 'firstName', 'lastName', 'email', 'phone', 'data', 'createdAt', 'updatedAt']);
for (const row of rows) {
for (const key of Object.keys(row)) {
expect(allowedKeys.has(key), `unexpected contact field "${key}"`).to.equal(true);
}
}
});

it('pages with limit and after', async () => {
const stamp = Date.now();
await setup({ subscriberId: `p1-${stamp}` });
await setup({ subscriberId: `p2-${stamp}` });

const first = await session.testAgent.get('/v1/human/contacts').query({ limit: 1 });
expect(first.status).to.equal(200);
expect(first.body.data).to.have.length(1);
expect(first.body.next).to.be.a('string');

const second = await session.testAgent.get('/v1/human/contacts').query({ limit: 1, after: first.body.next });
expect(second.status).to.equal(200);
expect(second.body.data).to.have.length(1);
expect(second.body.data[0].id).to.not.equal(first.body.data[0].id);
});

it('returns an empty page for a malformed cursor', async () => {
const res = await session.testAgent.get('/v1/human/contacts').query({ after: 'not-a-cursor' });
expect(res.status).to.equal(200);
expect(res.body.data).to.deep.equal([]);
expect(res.body.next).to.equal(null);
});
});
});
32 changes: 31 additions & 1 deletion apps/api/src/app/human/human-interactions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { KeylessAccessible } from '../shared/framework/swagger/keyless.security'
import { UserSession } from '../shared/framework/user.decorator';
import { CreateInteractionRequestDto } from './dtos/create-interaction-request.dto';
import { InteractionResponseDto } from './dtos/interaction-response.dto';
import { ListContactsQueryDto, ListContactsResponseDto } from './dtos/list-contacts.dto';
import { ListInteractionsQueryDto } from './dtos/list-interactions-query.dto';
import { SetupHumanRelayRequestDto, SetupHumanRelayResponseDto } from './dtos/setup-human-relay.dto';
import { CancelInteractionCommand } from './usecases/cancel-interaction/cancel-interaction.command';
Expand All @@ -28,6 +29,8 @@ import { CreateInteractionCommand } from './usecases/create-interaction/create-i
import { CreateInteraction } from './usecases/create-interaction/create-interaction.usecase';
import { GetInteractionCommand } from './usecases/get-interaction/get-interaction.command';
import { GetInteraction } from './usecases/get-interaction/get-interaction.usecase';
import { ListContactsCommand } from './usecases/list-contacts/list-contacts.command';
import { ListContacts } from './usecases/list-contacts/list-contacts.usecase';
import { ListInteractionsCommand } from './usecases/list-interactions/list-interactions.command';
import { ListInteractions } from './usecases/list-interactions/list-interactions.usecase';
import { SetupHumanRelayCommand } from './usecases/setup-human-relay/setup-human-relay.command';
Expand All @@ -44,7 +47,8 @@ export class HumanInteractionsController {
private readonly getInteractionUsecase: GetInteraction,
private readonly listInteractionsUsecase: ListInteractions,
private readonly cancelInteractionUsecase: CancelInteraction,
private readonly setupHumanRelayUsecase: SetupHumanRelay
private readonly setupHumanRelayUsecase: SetupHumanRelay,
private readonly listContactsUsecase: ListContacts
) {}

@Post('/interactions')
Expand Down Expand Up @@ -130,6 +134,30 @@ export class HumanInteractionsController {
);
}

/**
* Contacts are the environment's subscribers — the people an agent can
* address with `--to`. Deliberately a thin subscriber list today; filters
* and a per-contact `channels` field are the intended extension points.
*/
@Get('/contacts')
@KeylessAccessible()
@ExternalApiAccessible()
@RequirePermissions(PermissionsEnum.AGENT_READ)
listContacts(
@UserSession() user: UserSessionData,
@Query() query: ListContactsQueryDto
): Promise<ListContactsResponseDto> {
return this.listContactsUsecase.execute(
ListContactsCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
limit: query.limit,
after: query.after,
})
);
}

@Post('/setup')
@HttpCode(HttpStatus.OK)
@KeylessAccessible()
Expand All @@ -147,6 +175,8 @@ export class HumanInteractionsController {
subscriberId: body.subscriberId,
agentIdentifier: body.agentIdentifier,
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
})
);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app/human/human.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { HumanDeliveryService } from './services/human-delivery.service';
import { CancelInteraction } from './usecases/cancel-interaction/cancel-interaction.usecase';
import { CreateInteraction } from './usecases/create-interaction/create-interaction.usecase';
import { GetInteraction } from './usecases/get-interaction/get-interaction.usecase';
import { ListContacts } from './usecases/list-contacts/list-contacts.usecase';
import { ListInteractions } from './usecases/list-interactions/list-interactions.usecase';
import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay.usecase';

Expand All @@ -37,6 +38,7 @@ import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay.
ListInteractions,
CancelInteraction,
SetupHumanRelay,
ListContacts,
],
})
export class HumanModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
import { MAX_CONTACTS_LIMIT } from '../../dtos/list-contacts.dto';

export class ListContactsCommand extends EnvironmentWithUserCommand {
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_CONTACTS_LIMIT)
limit?: number;

@IsOptional()
@IsString()
after?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { InstrumentUsecase } from '@novu/application-generic';
import { BaseRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal';
import { DirectionEnum } from '../../../shared/dtos/base-responses';
import { DEFAULT_CONTACTS_LIMIT, HumanContactDto, ListContactsResponseDto } from '../../dtos/list-contacts.dto';
import { ListContactsCommand } from './list-contacts.command';

/**
* Lists the environment's subscribers as contacts. Backed by the same
* repository pagination as `GET /v2/subscribers`, but exposed under
* `/v1/human` so it is keyless-reachable by the `human` CLI and can grow
* human-specific filters (and a per-contact `channels` field) later.
*/
@Injectable()
export class ListContacts {
constructor(private readonly subscriberRepository: SubscriberRepository) {}

@InstrumentUsecase()
async execute(command: ListContactsCommand): Promise<ListContactsResponseDto> {
// A cursor that is not an internal id can never match a row; return an
// empty page instead of letting the repository throw on a bad ObjectId.
if (command.after && !BaseRepository.isInternalId(command.after)) {
return { data: [], next: null };
}

const page = await this.subscriberRepository.listSubscribers({
environmentId: command.environmentId,
organizationId: command.organizationId,
limit: command.limit ?? DEFAULT_CONTACTS_LIMIT,
after: command.after,
sortBy: '_id',
sortDirection: DirectionEnum.DESC,
});

return {
data: page.subscribers.map(toContact),
next: page.next,
};
}
}

function toContact(subscriber: SubscriberEntity): HumanContactDto {
return {
id: subscriber.subscriberId,
...(subscriber.firstName ? { firstName: subscriber.firstName } : {}),
...(subscriber.lastName ? { lastName: subscriber.lastName } : {}),
...(subscriber.email ? { email: subscriber.email } : {}),
...(subscriber.phone ? { phone: subscriber.phone } : {}),
...(subscriber.data ? { data: subscriber.data } : {}),
createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ export class SetupHumanRelayCommand extends EnvironmentWithUserCommand {
@IsOptional()
@IsString()
email?: string;

@IsOptional()
@IsString()
firstName?: string;

@IsOptional()
@IsString()
lastName?: string;
}
Loading
Loading