Skip to content

Commit 3696076

Browse files
committed
create POST /v1/people/bulk endpoint
1 parent 4f77c77 commit 3696076

File tree

3 files changed

+299
-0
lines changed

3 files changed

+299
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import { IsArray, ValidateNested, ArrayMinSize, ArrayMaxSize } from 'class-validator';
4+
import { CreatePeopleDto } from './create-people.dto';
5+
6+
export class BulkCreatePeopleDto {
7+
@ApiProperty({
8+
description: 'Array of members to create',
9+
type: [CreatePeopleDto],
10+
example: [
11+
{
12+
userId: 'usr_abc123def456',
13+
role: 'admin',
14+
department: 'it',
15+
isActive: true,
16+
fleetDmLabelId: 123,
17+
},
18+
{
19+
userId: 'usr_def456ghi789',
20+
role: 'member',
21+
department: 'hr',
22+
isActive: true,
23+
},
24+
],
25+
})
26+
@IsArray()
27+
@ArrayMinSize(1, { message: 'Members array cannot be empty' })
28+
@ArrayMaxSize(100, { message: 'Maximum 100 members allowed per bulk request' })
29+
@ValidateNested({ each: true })
30+
@Type(() => CreatePeopleDto)
31+
members: CreatePeopleDto[];
32+
}

apps/api/src/people/people.controller.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
2525
import type { AuthContext as AuthContextType } from '../auth/types';
2626
import { CreatePeopleDto } from './dto/create-people.dto';
2727
import { UpdatePeopleDto } from './dto/update-people.dto';
28+
import { BulkCreatePeopleDto } from './dto/bulk-create-people.dto';
2829
import { PeopleResponseDto } from './dto/people-responses.dto';
2930
import { PeopleService } from './people.service';
3031

@@ -225,6 +226,152 @@ export class PeopleController {
225226
};
226227
}
227228

229+
@Post('bulk')
230+
@ApiOperation({
231+
summary: 'Add multiple members to organization',
232+
description:
233+
'Bulk adds multiple members to the authenticated organization. Each member must have a valid user ID that exists in the system. Members who already exist in the organization or have invalid data will be skipped with error details returned. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
234+
})
235+
@ApiBody({
236+
description: 'Bulk member creation data',
237+
type: BulkCreatePeopleDto,
238+
})
239+
@ApiResponse({
240+
status: 201,
241+
description: 'Bulk member creation completed',
242+
schema: {
243+
type: 'object',
244+
properties: {
245+
created: {
246+
type: 'array',
247+
items: { $ref: '#/components/schemas/PeopleResponseDto' },
248+
description: 'Successfully created members',
249+
},
250+
errors: {
251+
type: 'array',
252+
items: {
253+
type: 'object',
254+
properties: {
255+
index: {
256+
type: 'number',
257+
description: 'Index in the original array where the error occurred',
258+
example: 2,
259+
},
260+
userId: {
261+
type: 'string',
262+
description: 'User ID that failed to be added',
263+
example: 'usr_abc123def456',
264+
},
265+
error: {
266+
type: 'string',
267+
description: 'Error message explaining why the member could not be created',
268+
example: 'User [email protected] is already a member of this organization',
269+
},
270+
},
271+
},
272+
description: 'Members that failed to be created with error details',
273+
},
274+
summary: {
275+
type: 'object',
276+
properties: {
277+
total: {
278+
type: 'number',
279+
description: 'Total number of members in the request',
280+
example: 5,
281+
},
282+
successful: {
283+
type: 'number',
284+
description: 'Number of members successfully created',
285+
example: 3,
286+
},
287+
failed: {
288+
type: 'number',
289+
description: 'Number of members that failed to be created',
290+
example: 2,
291+
},
292+
},
293+
},
294+
authType: {
295+
type: 'string',
296+
enum: ['api-key', 'session'],
297+
description: 'How the request was authenticated',
298+
},
299+
authenticatedUser: {
300+
type: 'object',
301+
properties: {
302+
id: {
303+
type: 'string',
304+
description: 'User ID',
305+
example: 'usr_abc123def456',
306+
},
307+
email: {
308+
type: 'string',
309+
description: 'User email',
310+
example: '[email protected]',
311+
},
312+
},
313+
},
314+
},
315+
},
316+
})
317+
@ApiResponse({
318+
status: 400,
319+
description: 'Bad Request - Invalid bulk data or validation errors',
320+
schema: {
321+
type: 'object',
322+
properties: {
323+
message: {
324+
type: 'string',
325+
examples: [
326+
'Validation failed',
327+
'Members array cannot be empty',
328+
'Maximum 100 members allowed per bulk request',
329+
],
330+
},
331+
},
332+
},
333+
})
334+
@ApiResponse({
335+
status: 401,
336+
description:
337+
'Unauthorized - Invalid authentication or insufficient permissions',
338+
})
339+
@ApiResponse({
340+
status: 404,
341+
description: 'Organization not found',
342+
schema: {
343+
type: 'object',
344+
properties: {
345+
message: {
346+
type: 'string',
347+
example: 'Organization with ID org_abc123def456 not found',
348+
},
349+
},
350+
},
351+
})
352+
@ApiResponse({
353+
status: 500,
354+
description: 'Internal server error',
355+
})
356+
async bulkCreateMembers(
357+
@Body() bulkCreateData: BulkCreatePeopleDto,
358+
@OrganizationId() organizationId: string,
359+
@AuthContext() authContext: AuthContextType,
360+
) {
361+
const result = await this.peopleService.bulkCreate(organizationId, bulkCreateData);
362+
363+
return {
364+
...result,
365+
authType: authContext.authType,
366+
...(authContext.userId && authContext.userEmail && {
367+
authenticatedUser: {
368+
id: authContext.userId,
369+
email: authContext.userEmail,
370+
},
371+
}),
372+
};
373+
}
374+
228375
@Get(':id')
229376
@ApiOperation({
230377
summary: 'Get person by ID',

apps/api/src/people/people.service.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db } from '@trycompai/db';
33
import type { PeopleResponseDto } from './dto/people-responses.dto';
44
import type { CreatePeopleDto } from './dto/create-people.dto';
55
import type { UpdatePeopleDto } from './dto/update-people.dto';
6+
import type { BulkCreatePeopleDto } from './dto/bulk-create-people.dto';
67

78
@Injectable()
89
export class PeopleService {
@@ -195,6 +196,125 @@ export class PeopleService {
195196
}
196197
}
197198

199+
async bulkCreate(organizationId: string, bulkCreateData: BulkCreatePeopleDto): Promise<{
200+
created: PeopleResponseDto[];
201+
errors: Array<{ index: number; userId: string; error: string }>;
202+
summary: { total: number; successful: number; failed: number };
203+
}> {
204+
try {
205+
// First verify the organization exists
206+
const organization = await db.organization.findUnique({
207+
where: { id: organizationId },
208+
select: { id: true, name: true },
209+
});
210+
211+
if (!organization) {
212+
throw new NotFoundException(`Organization with ID ${organizationId} not found`);
213+
}
214+
215+
const created: PeopleResponseDto[] = [];
216+
const errors: Array<{ index: number; userId: string; error: string }> = [];
217+
218+
// Process each member in the bulk request
219+
for (let i = 0; i < bulkCreateData.members.length; i++) {
220+
const memberData = bulkCreateData.members[i];
221+
try {
222+
// Verify the user exists
223+
const user = await db.user.findUnique({
224+
where: { id: memberData.userId },
225+
select: { id: true, name: true, email: true },
226+
});
227+
228+
if (!user) {
229+
errors.push({
230+
index: i,
231+
userId: memberData.userId,
232+
error: `User with ID ${memberData.userId} not found`,
233+
});
234+
continue;
235+
}
236+
237+
// Check if user is already a member of this organization
238+
const existingMember = await db.member.findFirst({
239+
where: {
240+
userId: memberData.userId,
241+
organizationId,
242+
},
243+
});
244+
245+
if (existingMember) {
246+
errors.push({
247+
index: i,
248+
userId: memberData.userId,
249+
error: `User ${user.email} is already a member of this organization`,
250+
});
251+
continue;
252+
}
253+
254+
// Create the new member
255+
const member = await db.member.create({
256+
data: {
257+
organizationId,
258+
userId: memberData.userId,
259+
role: memberData.role,
260+
department: memberData.department || 'none',
261+
isActive: memberData.isActive ?? true,
262+
fleetDmLabelId: memberData.fleetDmLabelId || null,
263+
},
264+
select: {
265+
id: true,
266+
organizationId: true,
267+
userId: true,
268+
role: true,
269+
createdAt: true,
270+
department: true,
271+
isActive: true,
272+
fleetDmLabelId: true,
273+
user: {
274+
select: {
275+
id: true,
276+
name: true,
277+
email: true,
278+
emailVerified: true,
279+
image: true,
280+
createdAt: true,
281+
updatedAt: true,
282+
lastLogin: true,
283+
},
284+
},
285+
},
286+
});
287+
288+
created.push(member);
289+
this.logger.log(`Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`);
290+
} catch (error) {
291+
errors.push({
292+
index: i,
293+
userId: memberData.userId,
294+
error: error.message || 'Unknown error occurred',
295+
});
296+
this.logger.error(`Failed to create member at index ${i} (userId: ${memberData.userId}):`, error);
297+
}
298+
}
299+
300+
const summary = {
301+
total: bulkCreateData.members.length,
302+
successful: created.length,
303+
failed: errors.length,
304+
};
305+
306+
this.logger.log(`Bulk create completed for organization ${organizationId}: ${summary.successful}/${summary.total} successful`);
307+
308+
return { created, errors, summary };
309+
} catch (error) {
310+
if (error instanceof NotFoundException) {
311+
throw error;
312+
}
313+
this.logger.error(`Failed to bulk create members for organization ${organizationId}:`, error);
314+
throw new Error(`Failed to bulk create members: ${error.message}`);
315+
}
316+
}
317+
198318
async updateById(memberId: string, organizationId: string, updateData: UpdatePeopleDto): Promise<PeopleResponseDto> {
199319
try {
200320
// First verify the organization exists

0 commit comments

Comments
 (0)