Skip to content

Commit c432067

Browse files
[dev] [tofikwest] tofik/update-organization-owner (#1876)
* feat(organization): add transfer ownership functionality and do visible the section del organization only for owner * feat(organization): implement transfer ownership endpoint and update related schemas * fix(organization): throw BadRequestException for missing user ID in transfer ownership --------- Co-authored-by: Tofik Hasanov <[email protected]>
1 parent e85e8c3 commit c432067

File tree

10 files changed

+798
-20
lines changed

10 files changed

+798
-20
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface TransferOwnershipDto {
2+
newOwnerId: string;
3+
}
4+
5+
export interface TransferOwnershipResponseDto {
6+
success: boolean;
7+
message: string;
8+
currentOwner?: {
9+
memberId: string;
10+
previousRoles: string[];
11+
newRoles: string[];
12+
};
13+
newOwner?: {
14+
memberId: string;
15+
previousRoles: string[];
16+
newRoles: string[];
17+
};
18+
}
19+

apps/api/src/organization/organization.controller.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
2+
BadRequestException,
23
Body,
34
Controller,
45
Delete,
56
Get,
67
Patch,
8+
Post,
79
UseGuards,
810
} from '@nestjs/common';
911
import {
@@ -22,11 +24,16 @@ import {
2224
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
2325
import type { AuthContext as AuthContextType } from '../auth/types';
2426
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
27+
import type { TransferOwnershipDto } from './dto/transfer-ownership.dto';
2528
import { OrganizationService } from './organization.service';
2629
import { GET_ORGANIZATION_RESPONSES } from './schemas/get-organization.responses';
2730
import { UPDATE_ORGANIZATION_RESPONSES } from './schemas/update-organization.responses';
2831
import { DELETE_ORGANIZATION_RESPONSES } from './schemas/delete-organization.responses';
29-
import { UPDATE_ORGANIZATION_BODY } from './schemas/organization-api-bodies';
32+
import { TRANSFER_OWNERSHIP_RESPONSES } from './schemas/transfer-ownership.responses';
33+
import {
34+
UPDATE_ORGANIZATION_BODY,
35+
TRANSFER_OWNERSHIP_BODY,
36+
} from './schemas/organization-api-bodies';
3037
import { ORGANIZATION_OPERATIONS } from './schemas/organization-operations';
3138

3239
@ApiTags('Organization')
@@ -96,6 +103,42 @@ export class OrganizationController {
96103
};
97104
}
98105

106+
@Post('transfer-ownership')
107+
@ApiOperation(ORGANIZATION_OPERATIONS.transferOwnership)
108+
@ApiBody(TRANSFER_OWNERSHIP_BODY)
109+
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[200])
110+
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[400])
111+
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[401])
112+
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[403])
113+
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[404])
114+
async transferOwnership(
115+
@OrganizationId() organizationId: string,
116+
@AuthContext() authContext: AuthContextType,
117+
@Body() transferData: TransferOwnershipDto,
118+
) {
119+
if (!authContext.userId) {
120+
throw new BadRequestException(
121+
'User ID is required for this operation. This endpoint requires session authentication.',
122+
);
123+
}
124+
125+
const result = await this.organizationService.transferOwnership(
126+
organizationId,
127+
authContext.userId,
128+
transferData.newOwnerId,
129+
);
130+
131+
return {
132+
...result,
133+
authType: authContext.authType,
134+
// Include user context for session auth (helpful for debugging)
135+
authenticatedUser: {
136+
id: authContext.userId,
137+
email: authContext.userEmail,
138+
},
139+
};
140+
}
141+
99142
@Delete()
100143
@ApiOperation(ORGANIZATION_OPERATIONS.deleteOrganization)
101144
@ApiResponse(DELETE_ORGANIZATION_RESPONSES[200])

apps/api/src/organization/organization.service.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
2-
import { db } from '@trycompai/db';
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
Logger,
5+
BadRequestException,
6+
ForbiddenException,
7+
} from '@nestjs/common';
8+
import { db, Role } from '@trycompai/db';
39
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
10+
import type { TransferOwnershipResponseDto } from './dto/transfer-ownership.dto';
411

512
@Injectable()
613
export class OrganizationService {
@@ -126,4 +133,144 @@ export class OrganizationService {
126133
throw error;
127134
}
128135
}
136+
137+
async transferOwnership(
138+
organizationId: string,
139+
currentUserId: string,
140+
newOwnerId: string,
141+
): Promise<TransferOwnershipResponseDto> {
142+
try {
143+
// Validate input
144+
if (!newOwnerId || newOwnerId.trim() === '') {
145+
throw new BadRequestException('New owner must be selected');
146+
}
147+
148+
// Get current user's member record
149+
const currentUserMember = await db.member.findFirst({
150+
where: { organizationId, userId: currentUserId },
151+
});
152+
153+
if (!currentUserMember) {
154+
throw new ForbiddenException(
155+
'Current user is not a member of this organization',
156+
);
157+
}
158+
159+
// Check if current user is the owner
160+
const currentUserRoles =
161+
currentUserMember.role?.split(',').map((r) => r.trim()) ?? [];
162+
if (!currentUserRoles.includes(Role.owner)) {
163+
throw new ForbiddenException(
164+
'Only the organization owner can transfer ownership',
165+
);
166+
}
167+
168+
// Get new owner's member record
169+
const newOwnerMember = await db.member.findFirst({
170+
where: {
171+
id: newOwnerId,
172+
organizationId,
173+
deactivated: false,
174+
},
175+
});
176+
177+
if (!newOwnerMember) {
178+
throw new NotFoundException('New owner not found or is deactivated');
179+
}
180+
181+
// Prevent transferring to self
182+
if (newOwnerMember.userId === currentUserId) {
183+
throw new BadRequestException(
184+
'You cannot transfer ownership to yourself',
185+
);
186+
}
187+
188+
// Parse new owner's current roles
189+
const newOwnerRoles =
190+
newOwnerMember.role?.split(',').map((r) => r.trim()) ?? [];
191+
192+
// Check if new owner already has owner role (shouldn't happen, but safety check)
193+
if (newOwnerRoles.includes(Role.owner)) {
194+
throw new BadRequestException('Selected member is already an owner');
195+
}
196+
197+
// Prepare updated roles for current owner:
198+
// Remove 'owner', add 'admin' if not present, keep all other roles
199+
const updatedCurrentOwnerRoles = currentUserRoles
200+
.filter((role) => role !== Role.owner) // Remove owner
201+
.concat(currentUserRoles.includes(Role.admin) ? [] : [Role.admin]); // Add admin if not present
202+
203+
// Prepare updated roles for new owner:
204+
// Add 'owner', keep all existing roles
205+
const updatedNewOwnerRoles = [
206+
...new Set([...newOwnerRoles, Role.owner]),
207+
]; // Use Set to avoid duplicates
208+
209+
this.logger.log('[Transfer Ownership] Role updates:', {
210+
organizationId,
211+
currentOwner: {
212+
memberId: currentUserMember.id,
213+
userId: currentUserId,
214+
before: currentUserRoles,
215+
after: updatedCurrentOwnerRoles,
216+
},
217+
newOwner: {
218+
memberId: newOwnerMember.id,
219+
userId: newOwnerMember.userId,
220+
before: newOwnerRoles,
221+
after: updatedNewOwnerRoles,
222+
},
223+
});
224+
225+
// Update both members in a transaction
226+
await db.$transaction([
227+
// Remove owner role from current user and add admin role (keep other roles)
228+
db.member.update({
229+
where: { id: currentUserMember.id },
230+
data: {
231+
role: updatedCurrentOwnerRoles.sort().join(','),
232+
},
233+
}),
234+
// Add owner role to new owner (keep all existing roles)
235+
db.member.update({
236+
where: { id: newOwnerMember.id },
237+
data: {
238+
role: updatedNewOwnerRoles.sort().join(','),
239+
},
240+
}),
241+
]);
242+
243+
this.logger.log(
244+
`Ownership transferred successfully for organization ${organizationId}`,
245+
);
246+
247+
return {
248+
success: true,
249+
message: 'Ownership transferred successfully',
250+
currentOwner: {
251+
memberId: currentUserMember.id,
252+
previousRoles: currentUserRoles,
253+
newRoles: updatedCurrentOwnerRoles,
254+
},
255+
newOwner: {
256+
memberId: newOwnerMember.id,
257+
previousRoles: newOwnerRoles,
258+
newRoles: updatedNewOwnerRoles,
259+
},
260+
};
261+
} catch (error) {
262+
if (
263+
error instanceof NotFoundException ||
264+
error instanceof BadRequestException ||
265+
error instanceof ForbiddenException
266+
) {
267+
throw error;
268+
}
269+
this.logger.error(
270+
`Failed to transfer ownership for organization ${organizationId}:`,
271+
error,
272+
);
273+
throw error;
274+
}
275+
}
129276
}

apps/api/src/organization/schemas/organization-api-bodies.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,19 @@ export const UPDATE_ORGANIZATION_BODY: ApiBodyOptions = {
5454
additionalProperties: false,
5555
},
5656
};
57+
58+
export const TRANSFER_OWNERSHIP_BODY: ApiBodyOptions = {
59+
description: 'Transfer organization ownership to another member',
60+
schema: {
61+
type: 'object',
62+
required: ['newOwnerId'],
63+
properties: {
64+
newOwnerId: {
65+
type: 'string',
66+
description: 'Member ID of the new owner',
67+
example: 'mem_xyz789',
68+
},
69+
},
70+
additionalProperties: false,
71+
},
72+
};

apps/api/src/organization/schemas/organization-operations.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ export const ORGANIZATION_OPERATIONS: Record<string, ApiOperationOptions> = {
1616
description:
1717
'Permanently deletes the authenticated organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
1818
},
19+
transferOwnership: {
20+
summary: 'Transfer organization ownership',
21+
description:
22+
'Transfers organization ownership to another member. The current owner will become an admin and keep all other roles. The new owner will receive the owner role while keeping their existing roles. Only the current organization owner can perform this action. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
23+
},
1924
};

0 commit comments

Comments
 (0)