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
19 changes: 19 additions & 0 deletions apps/api/src/organization/dto/transfer-ownership.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface TransferOwnershipDto {
newOwnerId: string;
}

export interface TransferOwnershipResponseDto {
success: boolean;
message: string;
currentOwner?: {
memberId: string;
previousRoles: string[];
newRoles: string[];
};
newOwner?: {
memberId: string;
previousRoles: string[];
newRoles: string[];
};
}

45 changes: 44 additions & 1 deletion apps/api/src/organization/organization.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
Expand All @@ -22,11 +24,16 @@ import {
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import type { AuthContext as AuthContextType } from '../auth/types';
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
import type { TransferOwnershipDto } from './dto/transfer-ownership.dto';
import { OrganizationService } from './organization.service';
import { GET_ORGANIZATION_RESPONSES } from './schemas/get-organization.responses';
import { UPDATE_ORGANIZATION_RESPONSES } from './schemas/update-organization.responses';
import { DELETE_ORGANIZATION_RESPONSES } from './schemas/delete-organization.responses';
import { UPDATE_ORGANIZATION_BODY } from './schemas/organization-api-bodies';
import { TRANSFER_OWNERSHIP_RESPONSES } from './schemas/transfer-ownership.responses';
import {
UPDATE_ORGANIZATION_BODY,
TRANSFER_OWNERSHIP_BODY,
} from './schemas/organization-api-bodies';
import { ORGANIZATION_OPERATIONS } from './schemas/organization-operations';

@ApiTags('Organization')
Expand Down Expand Up @@ -96,6 +103,42 @@ export class OrganizationController {
};
}

@Post('transfer-ownership')
@ApiOperation(ORGANIZATION_OPERATIONS.transferOwnership)
@ApiBody(TRANSFER_OWNERSHIP_BODY)
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[200])
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[400])
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[401])
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[403])
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[404])
async transferOwnership(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Body() transferData: TransferOwnershipDto,
) {
if (!authContext.userId) {
throw new BadRequestException(
'User ID is required for this operation. This endpoint requires session authentication.',
);
}

const result = await this.organizationService.transferOwnership(
organizationId,
authContext.userId,
transferData.newOwnerId,
);

return {
...result,
authType: authContext.authType,
// Include user context for session auth (helpful for debugging)
authenticatedUser: {
id: authContext.userId,
email: authContext.userEmail,
},
};
}

@Delete()
@ApiOperation(ORGANIZATION_OPERATIONS.deleteOrganization)
@ApiResponse(DELETE_ORGANIZATION_RESPONSES[200])
Expand Down
151 changes: 149 additions & 2 deletions apps/api/src/organization/organization.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { db } from '@trycompai/db';
import {
Injectable,
NotFoundException,
Logger,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { db, Role } from '@trycompai/db';
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
import type { TransferOwnershipResponseDto } from './dto/transfer-ownership.dto';

@Injectable()
export class OrganizationService {
Expand Down Expand Up @@ -126,4 +133,144 @@ export class OrganizationService {
throw error;
}
}

async transferOwnership(
organizationId: string,
currentUserId: string,
newOwnerId: string,
): Promise<TransferOwnershipResponseDto> {
try {
// Validate input
if (!newOwnerId || newOwnerId.trim() === '') {
throw new BadRequestException('New owner must be selected');
}

// Get current user's member record
const currentUserMember = await db.member.findFirst({
where: { organizationId, userId: currentUserId },
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Deactivated owner can still transfer ownership

The query for currentUserMember does not filter by deactivated: false, but the newOwnerMember query does. This inconsistency could allow a deactivated member who still has the owner role to transfer ownership, even though they are supposed to be inactive. Adding deactivated: false to the currentUserMember query would make the checks consistent.

Fix in Cursor Fix in Web


if (!currentUserMember) {
throw new ForbiddenException(
'Current user is not a member of this organization',
);
}

// Check if current user is the owner
const currentUserRoles =
currentUserMember.role?.split(',').map((r) => r.trim()) ?? [];
if (!currentUserRoles.includes(Role.owner)) {
throw new ForbiddenException(
'Only the organization owner can transfer ownership',
);
}

// Get new owner's member record
const newOwnerMember = await db.member.findFirst({
where: {
id: newOwnerId,
organizationId,
deactivated: false,
},
});

if (!newOwnerMember) {
throw new NotFoundException('New owner not found or is deactivated');
}

// Prevent transferring to self
if (newOwnerMember.userId === currentUserId) {
throw new BadRequestException(
'You cannot transfer ownership to yourself',
);
}

// Parse new owner's current roles
const newOwnerRoles =
newOwnerMember.role?.split(',').map((r) => r.trim()) ?? [];

// Check if new owner already has owner role (shouldn't happen, but safety check)
if (newOwnerRoles.includes(Role.owner)) {
throw new BadRequestException('Selected member is already an owner');
}

// Prepare updated roles for current owner:
// Remove 'owner', add 'admin' if not present, keep all other roles
const updatedCurrentOwnerRoles = currentUserRoles
.filter((role) => role !== Role.owner) // Remove owner
.concat(currentUserRoles.includes(Role.admin) ? [] : [Role.admin]); // Add admin if not present

// Prepare updated roles for new owner:
// Add 'owner', keep all existing roles
const updatedNewOwnerRoles = [
...new Set([...newOwnerRoles, Role.owner]),
]; // Use Set to avoid duplicates

this.logger.log('[Transfer Ownership] Role updates:', {
organizationId,
currentOwner: {
memberId: currentUserMember.id,
userId: currentUserId,
before: currentUserRoles,
after: updatedCurrentOwnerRoles,
},
newOwner: {
memberId: newOwnerMember.id,
userId: newOwnerMember.userId,
before: newOwnerRoles,
after: updatedNewOwnerRoles,
},
});

// Update both members in a transaction
await db.$transaction([
// Remove owner role from current user and add admin role (keep other roles)
db.member.update({
where: { id: currentUserMember.id },
data: {
role: updatedCurrentOwnerRoles.sort().join(','),
},
}),
// Add owner role to new owner (keep all existing roles)
db.member.update({
where: { id: newOwnerMember.id },
data: {
role: updatedNewOwnerRoles.sort().join(','),
},
}),
]);

this.logger.log(
`Ownership transferred successfully for organization ${organizationId}`,
);

return {
success: true,
message: 'Ownership transferred successfully',
currentOwner: {
memberId: currentUserMember.id,
previousRoles: currentUserRoles,
newRoles: updatedCurrentOwnerRoles,
},
newOwner: {
memberId: newOwnerMember.id,
previousRoles: newOwnerRoles,
newRoles: updatedNewOwnerRoles,
},
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(
`Failed to transfer ownership for organization ${organizationId}:`,
error,
);
throw error;
}
}
}
16 changes: 16 additions & 0 deletions apps/api/src/organization/schemas/organization-api-bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,19 @@ export const UPDATE_ORGANIZATION_BODY: ApiBodyOptions = {
additionalProperties: false,
},
};

export const TRANSFER_OWNERSHIP_BODY: ApiBodyOptions = {
description: 'Transfer organization ownership to another member',
schema: {
type: 'object',
required: ['newOwnerId'],
properties: {
newOwnerId: {
type: 'string',
description: 'Member ID of the new owner',
example: 'mem_xyz789',
},
},
additionalProperties: false,
},
};
5 changes: 5 additions & 0 deletions apps/api/src/organization/schemas/organization-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ export const ORGANIZATION_OPERATIONS: Record<string, ApiOperationOptions> = {
description:
'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).',
},
transferOwnership: {
summary: 'Transfer organization ownership',
description:
'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).',
},
};
Loading
Loading