import { Controller, Get, Post, Delete, Param, Body, UseGuards, ForbiddenException, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { TenantId } from '../tenant/tenant.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { RecordShare } from '../models/record-share.model'; import { ObjectDefinition } from '../models/object-definition.model'; import { User } from '../models/user.model'; import { AuthorizationService } from './authorization.service'; import { CreateRecordShareDto } from './dto/create-record-share.dto'; @Controller('runtime/objects/:objectApiName/records/:recordId/shares') @UseGuards(JwtAuthGuard) export class RecordSharingController { constructor( private tenantDbService: TenantDatabaseService, private authService: AuthorizationService, ) {} @Get() async getRecordShares( @TenantId() tenantId: string, @Param('objectApiName') objectApiName: string, @Param('recordId') recordId: string, @CurrentUser() currentUser: any, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get object definition const objectDef = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDef) { throw new Error('Object not found'); } // Get the record to check ownership const tableName = this.getTableName(objectDef.apiName); const record = await knex(tableName) .where({ id: recordId }) .first(); if (!record) { throw new Error('Record not found'); } // Only owner can view shares if (record.ownerId !== currentUser.userId) { // Check if user has modify all permission const user: any = await User.query(knex) .findById(currentUser.userId) .withGraphFetched('roles.objectPermissions'); if (!user) { throw new ForbiddenException('User not found'); } const hasModifyAll = user.roles?.some(role => role.objectPermissions?.some( perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll ) ); if (!hasModifyAll) { throw new ForbiddenException('Only the record owner or users with Modify All permission can view shares'); } } // Get all active shares for this record const shares = await RecordShare.query(knex) .where({ objectDefinitionId: objectDef.id, recordId }) .whereNull('revokedAt') .where(builder => { builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date()); }) .withGraphFetched('[granteeUser]') .orderBy('createdAt', 'desc'); return shares; } @Post() async createRecordShare( @TenantId() tenantId: string, @Param('objectApiName') objectApiName: string, @Param('recordId') recordId: string, @CurrentUser() currentUser: any, @Body() data: CreateRecordShareDto, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get object definition const objectDef = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDef) { throw new Error('Object not found'); } // Get the record to check ownership const tableName = this.getTableName(objectDef.apiName); const record = await knex(tableName) .where({ id: recordId }) .first(); if (!record) { throw new Error('Record not found'); } // Check if user can share - either owner or has modify permissions const canShare = await this.canUserShareRecord( currentUser.userId, record, objectDef, knex, ); if (!canShare) { throw new ForbiddenException('You do not have permission to share this record'); } // Cannot share with self if (data.granteeUserId === currentUser.userId) { throw new Error('Cannot share record with yourself'); } // Check if share already exists const existingShare = await RecordShare.query(knex) .where({ objectDefinitionId: objectDef.id, recordId, granteeUserId: data.granteeUserId, }) .whereNull('revokedAt') .first(); if (existingShare) { // Update existing share const updated = await RecordShare.query(knex) .patchAndFetchById(existingShare.id, { accessLevel: { canRead: data.canRead, canEdit: data.canEdit, canDelete: data.canDelete, }, // Convert ISO string to MySQL datetime format expiresAt: data.expiresAt ? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) : null, } as any); return RecordShare.query(knex) .findById(updated.id) .withGraphFetched('[granteeUser]'); } // Create new share const share = await RecordShare.query(knex).insertAndFetch({ objectDefinitionId: objectDef.id, recordId, granteeUserId: data.granteeUserId, grantedByUserId: currentUser.userId, accessLevel: { canRead: data.canRead, canEdit: data.canEdit, canDelete: data.canDelete, }, // Convert ISO string to MySQL datetime format: YYYY-MM-DD HH:MM:SS expiresAt: data.expiresAt ? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) : null, } as any); return RecordShare.query(knex) .findById(share.id) .withGraphFetched('[granteeUser]'); } @Delete(':shareId') async deleteRecordShare( @TenantId() tenantId: string, @Param('objectApiName') objectApiName: string, @Param('recordId') recordId: string, @Param('shareId') shareId: string, @CurrentUser() currentUser: any, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get object definition const objectDef = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDef) { throw new Error('Object not found'); } // Get the record to check ownership const tableName = this.getTableName(objectDef.apiName); const record = await knex(tableName) .where({ id: recordId }) .first(); if (!record) { throw new Error('Record not found'); } // Only owner can revoke shares if (record.ownerId !== currentUser.userId) { // Check if user has modify all permission const user: any = await User.query(knex) .findById(currentUser.userId) .withGraphFetched('roles.objectPermissions'); if (!user) { throw new ForbiddenException('User not found'); } const hasModifyAll = user.roles?.some(role => role.objectPermissions?.some( perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll ) ); if (!hasModifyAll) { throw new ForbiddenException('Only the record owner or users with Modify All permission can revoke shares'); } } // Revoke the share (soft delete) await RecordShare.query(knex) .patchAndFetchById(shareId, { revokedAt: knex.fn.now() as any, }); return { success: true }; } private async canUserShareRecord( userId: string, record: any, objectDef: ObjectDefinition, knex: any, ): Promise { // Owner can always share if (record.ownerId === userId) { return true; } // Check if user has modify all or edit permissions const user: any = await User.query(knex) .findById(userId) .withGraphFetched('roles.objectPermissions'); if (!user) { return false; } // Check for canModifyAll permission const hasModifyAll = user.roles?.some(role => role.objectPermissions?.some( perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll ) ); if (hasModifyAll) { return true; } // Check for canEdit permission (user needs edit to share) const hasEdit = user.roles?.some(role => role.objectPermissions?.some( perm => perm.objectDefinitionId === objectDef.id && perm.canEdit ) ); // If user has edit permission, check if they can actually edit this record // by using the authorization service if (hasEdit) { try { await this.authService.assertCanPerformAction( 'update', objectDef, record, user, knex, ); return true; } catch { return false; } } return false; } private getTableName(apiName: string): string { // Convert CamelCase to snake_case and pluralize const snakeCase = apiName .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, ''); // Simple pluralization if (snakeCase.endsWith('y')) { return snakeCase.slice(0, -1) + 'ies'; } else if (snakeCase.endsWith('s')) { return snakeCase + 'es'; } else { return snakeCase + 's'; } } }