AI Development GuideEntity References
Reference Implementation - Documents
Complete Document module with access control and visibility
Reference Implementation: Document Module
This reference provides a complete Document module implementation with fine-grained access control, visibility settings, and file management.
Module Structure
apps/api/src/modules/documents/
├── documents.module.ts
├── documents.controller.ts
├── documents.repository.ts
├── documents.service.ts
├── dto/
│ ├── create-document.dto.ts
│ ├── update-document.dto.ts
│ ├── document-access.dto.ts
│ └── document-response.dto.ts
├── guards/
│ └── document-access.guard.ts
└── documents.controller.spec.tsDocument Module
// apps/api/src/modules/documents/documents.module.ts
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsRepository, DocumentsService],
exports: [DocumentsRepository, DocumentsService],
})
export class DocumentsModule {}DTOs
Create Document DTO
// apps/api/src/modules/documents/dto/create-document.dto.ts
import { IsString, IsNotEmpty, IsEnum, IsOptional, IsUUID, IsArray } from 'class-validator';
import { DocumentType, DocumentVisibility } from '@prisma/client';
export class CreateDocumentDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(DocumentType)
type: DocumentType;
@IsEnum(DocumentVisibility)
@IsOptional()
visibility?: DocumentVisibility = DocumentVisibility.PRIVATE;
@IsString()
@IsNotEmpty()
fileUrl: string;
@IsString()
@IsNotEmpty()
fileName: string;
@IsString()
@IsNotEmpty()
mimeType: string;
@IsNumber()
fileSize: number;
@IsUUID()
@IsOptional()
employeeId?: string;
@IsUUID()
@IsOptional()
folderId?: string;
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
tagIds?: string[];
}Update Document DTO
// apps/api/src/modules/documents/dto/update-document.dto.ts
import { IsString, IsEnum, IsOptional, IsUUID, IsArray } from 'class-validator';
import { DocumentVisibility } from '@prisma/client';
export class UpdateDocumentDto {
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(DocumentVisibility)
@IsOptional()
visibility?: DocumentVisibility;
@IsUUID()
@IsOptional()
folderId?: string;
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
tagIds?: string[];
}Document Access DTO
// apps/api/src/modules/documents/dto/document-access.dto.ts
import { IsUUID, IsEnum, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { DocumentVisibility } from '@prisma/client';
export class GrantAccessDto {
@IsUUID()
userId: string;
@IsEnum(['VIEW', 'EDIT', 'ADMIN'])
permission: 'VIEW' | 'EDIT' | 'ADMIN';
}
export class UpdateVisibilityDto {
@IsEnum(DocumentVisibility)
visibility: DocumentVisibility;
@IsArray()
@ValidateNested({ each: true })
@Type(() => GrantAccessDto)
@IsOptional()
customAccess?: GrantAccessDto[];
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
teamIds?: string[];
@IsArray()
@IsUUID('4', { each: true })
@IsOptional()
departmentIds?: string[];
}Document Response DTO
// apps/api/src/modules/documents/dto/document-response.dto.ts
import { DocumentType, DocumentVisibility } from '@prisma/client';
export class DocumentResponseDto {
id: string;
title: string;
description: string | null;
type: DocumentType;
visibility: DocumentVisibility;
fileUrl: string;
fileName: string;
mimeType: string;
fileSize: number;
employeeId: string | null;
folderId: string | null;
uploadedById: string;
createdAt: Date;
updatedAt: Date;
tags?: { id: string; name: string; color: string }[];
accessList?: { userId: string; permission: string }[];
canEdit: boolean;
canDelete: boolean;
canShare: boolean;
}Document Repository
// apps/api/src/modules/documents/documents.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, Document, DocumentVisibility } from '@prisma/client';
@Injectable()
export class DocumentsRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Prisma.DocumentCreateInput): Promise<Document> {
return this.prisma.document.create({
data,
include: {
tags: { include: { tag: true } },
uploadedBy: { select: { id: true, email: true } },
},
});
}
async findById(id: string, tenantId: string): Promise<Document | null> {
return this.prisma.document.findFirst({
where: { id, tenantId },
include: {
tags: { include: { tag: true } },
accessList: { include: { user: { select: { id: true, email: true } } } },
uploadedBy: { select: { id: true, email: true } },
folder: true,
employee: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async findAccessible(
tenantId: string,
userId: string,
employeeId: string | null,
teamIds: string[],
departmentIds: string[],
isManager: boolean,
options?: {
type?: string;
folderId?: string;
employeeId?: string;
search?: string;
skip?: number;
take?: number;
},
): Promise<{ documents: Document[]; total: number }> {
// Build visibility conditions based on user's access
const visibilityConditions: Prisma.DocumentWhereInput[] = [
// User's own uploads
{ uploadedById: userId },
// Public company documents
{ visibility: DocumentVisibility.COMPANY },
// Custom access granted
{ accessList: { some: { userId } } },
];
// Private - only owner
// Already covered by uploadedById check
// Team visibility - user must be in same team
if (teamIds.length > 0) {
visibilityConditions.push({
AND: [
{ visibility: DocumentVisibility.TEAM },
{
OR: [
// Document associated with user's teams
{ employee: { teamMemberships: { some: { teamId: { in: teamIds } } } } },
// Or has team access
{ accessList: { some: { userId } } },
],
},
],
});
}
// Department visibility
if (departmentIds.length > 0) {
visibilityConditions.push({
AND: [
{ visibility: DocumentVisibility.DEPARTMENT },
{ employee: { departmentId: { in: departmentIds } } },
],
});
}
// Managers visibility
if (isManager) {
visibilityConditions.push({ visibility: DocumentVisibility.MANAGERS });
}
const where: Prisma.DocumentWhereInput = {
tenantId,
OR: visibilityConditions,
...(options?.type && { type: options.type as any }),
...(options?.folderId && { folderId: options.folderId }),
...(options?.employeeId && { employeeId: options.employeeId }),
...(options?.search && {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ description: { contains: options.search, mode: 'insensitive' } },
{ fileName: { contains: options.search, mode: 'insensitive' } },
],
}),
};
const [documents, total] = await Promise.all([
this.prisma.document.findMany({
where,
include: {
tags: { include: { tag: true } },
uploadedBy: { select: { id: true, email: true } },
folder: true,
},
orderBy: { createdAt: 'desc' },
skip: options?.skip ?? 0,
take: options?.take ?? 20,
}),
this.prisma.document.count({ where }),
]);
return { documents, total };
}
async update(id: string, tenantId: string, data: Prisma.DocumentUpdateInput): Promise<Document> {
return this.prisma.document.update({
where: { id, tenantId },
data,
include: {
tags: { include: { tag: true } },
accessList: { include: { user: { select: { id: true, email: true } } } },
},
});
}
async delete(id: string, tenantId: string): Promise<void> {
await this.prisma.document.delete({
where: { id, tenantId },
});
}
async grantAccess(
documentId: string,
tenantId: string,
userId: string,
permission: string,
grantedById: string,
): Promise<void> {
await this.prisma.documentAccess.upsert({
where: {
documentId_userId: { documentId, userId },
},
create: {
documentId,
userId,
permission,
grantedById,
},
update: {
permission,
grantedById,
},
});
}
async revokeAccess(documentId: string, userId: string): Promise<void> {
await this.prisma.documentAccess.delete({
where: {
documentId_userId: { documentId, userId },
},
});
}
async getAccessList(documentId: string): Promise<any[]> {
return this.prisma.documentAccess.findMany({
where: { documentId },
include: {
user: { select: { id: true, email: true } },
},
});
}
async checkAccess(
documentId: string,
userId: string,
requiredPermission: 'VIEW' | 'EDIT' | 'ADMIN',
): Promise<boolean> {
const access = await this.prisma.documentAccess.findUnique({
where: {
documentId_userId: { documentId, userId },
},
});
if (!access) return false;
const permissionHierarchy = { VIEW: 1, EDIT: 2, ADMIN: 3 };
return permissionHierarchy[access.permission] >= permissionHierarchy[requiredPermission];
}
async syncTags(documentId: string, tagIds: string[], assignedById: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
// Remove existing tags
await tx.documentTag.deleteMany({
where: { documentId },
});
// Add new tags
if (tagIds.length > 0) {
await tx.documentTag.createMany({
data: tagIds.map((tagId) => ({
documentId,
tagId,
assignedById,
})),
});
}
});
}
}Document Service
// apps/api/src/modules/documents/documents.service.ts
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { DocumentsRepository } from './documents.repository';
import { DocumentVisibility } from '@prisma/client';
interface UserContext {
userId: string;
tenantId: string;
employeeId: string | null;
teamIds: string[];
departmentIds: string[];
isManager: boolean;
permissions: string[];
}
@Injectable()
export class DocumentsService {
constructor(private readonly repository: DocumentsRepository) {}
async canAccess(documentId: string, user: UserContext, requiredPermission: 'VIEW' | 'EDIT' | 'ADMIN'): Promise<boolean> {
const document = await this.repository.findById(documentId, user.tenantId);
if (!document) return false;
// Owner always has full access
if (document.uploadedById === user.userId) return true;
// Check explicit access grants
const hasExplicitAccess = await this.repository.checkAccess(documentId, user.userId, requiredPermission);
if (hasExplicitAccess) return true;
// View-only access based on visibility
if (requiredPermission === 'VIEW') {
return this.checkVisibilityAccess(document, user);
}
return false;
}
private checkVisibilityAccess(document: any, user: UserContext): boolean {
switch (document.visibility) {
case DocumentVisibility.PRIVATE:
return document.uploadedById === user.userId;
case DocumentVisibility.TEAM:
// User must be in the same team as document's associated employee
if (document.employee?.teamMemberships) {
const docTeamIds = document.employee.teamMemberships.map((m: any) => m.teamId);
return user.teamIds.some((id) => docTeamIds.includes(id));
}
return false;
case DocumentVisibility.DEPARTMENT:
if (document.employee?.departmentId) {
return user.departmentIds.includes(document.employee.departmentId);
}
return false;
case DocumentVisibility.MANAGERS:
return user.isManager;
case DocumentVisibility.COMPANY:
return true;
case DocumentVisibility.CUSTOM:
// Custom access is handled by explicit grants
return false;
default:
return false;
}
}
async getDocumentWithPermissions(documentId: string, user: UserContext): Promise<any> {
const document = await this.repository.findById(documentId, user.tenantId);
if (!document) {
throw new NotFoundException('Document not found');
}
const canView = await this.canAccess(documentId, user, 'VIEW');
if (!canView) {
throw new ForbiddenException('You do not have access to this document');
}
const canEdit = await this.canAccess(documentId, user, 'EDIT');
const canAdmin = await this.canAccess(documentId, user, 'ADMIN');
return {
...document,
canEdit,
canDelete: canAdmin || document.uploadedById === user.userId,
canShare: canAdmin || document.uploadedById === user.userId,
};
}
async updateVisibility(
documentId: string,
user: UserContext,
visibility: DocumentVisibility,
customAccess?: { userId: string; permission: string }[],
): Promise<void> {
const canAdmin = await this.canAccess(documentId, user, 'ADMIN');
const document = await this.repository.findById(documentId, user.tenantId);
if (!canAdmin && document?.uploadedById !== user.userId) {
throw new ForbiddenException('You cannot change visibility settings');
}
await this.repository.update(documentId, user.tenantId, { visibility });
// Handle custom access
if (visibility === DocumentVisibility.CUSTOM && customAccess) {
// Clear existing custom access
const existingAccess = await this.repository.getAccessList(documentId);
for (const access of existingAccess) {
if (access.userId !== user.userId) {
await this.repository.revokeAccess(documentId, access.userId);
}
}
// Grant new access
for (const grant of customAccess) {
await this.repository.grantAccess(
documentId,
user.tenantId,
grant.userId,
grant.permission,
user.userId,
);
}
}
}
}Document Controller
// apps/api/src/modules/documents/documents.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';
import { TenantGuard } from '../common/guards';
// Note: PermissionsGuard not yet implemented in MVP - add in later phase
import { RequirePermissions } from '../auth/decorators/permissions.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateDocumentDto } from './dto/create-document.dto';
import { UpdateDocumentDto } from './dto/update-document.dto';
import { UpdateVisibilityDto, GrantAccessDto } from './dto/document-access.dto';
@Controller('documents')
@UseGuards(TenantGuard)
export class DocumentsController {
constructor(
private readonly repository: DocumentsRepository,
private readonly service: DocumentsService,
) {}
@Post()
@RequirePermissions('documents:create')
async create(@CurrentUser() user: any, @Body() dto: CreateDocumentDto) {
const document = await this.repository.create({
...dto,
tenant: { connect: { id: user.tenantId } },
uploadedBy: { connect: { id: user.id } },
...(dto.employeeId && { employee: { connect: { id: dto.employeeId } } }),
...(dto.folderId && { folder: { connect: { id: dto.folderId } } }),
});
// Sync tags if provided
if (dto.tagIds?.length) {
await this.repository.syncTags(document.id, dto.tagIds, user.id);
}
return this.repository.findById(document.id, user.tenantId);
}
@Get()
@RequirePermissions('documents:read')
async findAll(
@CurrentUser() user: any,
@Query('type') type?: string,
@Query('folderId') folderId?: string,
@Query('employeeId') employeeId?: string,
@Query('search') search?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const pageNum = parseInt(page || '1', 10);
const limitNum = Math.min(parseInt(limit || '20', 10), 100);
const skip = (pageNum - 1) * limitNum;
const { documents, total } = await this.repository.findAccessible(
user.tenantId,
user.id,
user.employeeId,
user.teamIds || [],
user.departmentIds || [],
user.isManager || false,
{ type, folderId, employeeId, search, skip, take: limitNum },
);
return {
data: documents,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
},
};
}
@Get(':id')
@RequirePermissions('documents:read')
async findOne(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.service.getDocumentWithPermissions(id, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
});
}
@Put(':id')
@RequirePermissions('documents:update')
async update(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDocumentDto,
) {
const canEdit = await this.service.canAccess(id, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
}, 'EDIT');
if (!canEdit) {
throw new ForbiddenException('You cannot edit this document');
}
const { tagIds, ...updateData } = dto;
const document = await this.repository.update(id, user.tenantId, updateData);
if (tagIds !== undefined) {
await this.repository.syncTags(id, tagIds, user.id);
}
return this.repository.findById(id, user.tenantId);
}
@Put(':id/visibility')
@RequirePermissions('documents:update')
async updateVisibility(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateVisibilityDto,
) {
await this.service.updateVisibility(
id,
{
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
},
dto.visibility,
dto.customAccess,
);
return this.repository.findById(id, user.tenantId);
}
@Post(':id/access')
@RequirePermissions('documents:update')
async grantAccess(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: GrantAccessDto,
) {
const canAdmin = await this.service.canAccess(id, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
}, 'ADMIN');
const document = await this.repository.findById(id, user.tenantId);
if (!canAdmin && document?.uploadedById !== user.id) {
throw new ForbiddenException('You cannot grant access to this document');
}
await this.repository.grantAccess(id, user.tenantId, dto.userId, dto.permission, user.id);
return { success: true };
}
@Delete(':id/access/:userId')
@RequirePermissions('documents:update')
@HttpCode(HttpStatus.NO_CONTENT)
async revokeAccess(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
const canAdmin = await this.service.canAccess(id, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
}, 'ADMIN');
const document = await this.repository.findById(id, user.tenantId);
if (!canAdmin && document?.uploadedById !== user.id) {
throw new ForbiddenException('You cannot revoke access to this document');
}
await this.repository.revokeAccess(id, userId);
}
@Delete(':id')
@RequirePermissions('documents:delete')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
const canAdmin = await this.service.canAccess(id, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
}, 'ADMIN');
const document = await this.repository.findById(id, user.tenantId);
if (!canAdmin && document?.uploadedById !== user.id) {
throw new ForbiddenException('You cannot delete this document');
}
await this.repository.delete(id, user.tenantId);
}
}Document Access Guard
// apps/api/src/modules/documents/guards/document-access.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { DocumentsService } from '../documents.service';
export const DOCUMENT_PERMISSION_KEY = 'documentPermission';
export const DocumentPermission = (permission: 'VIEW' | 'EDIT' | 'ADMIN') =>
SetMetadata(DOCUMENT_PERMISSION_KEY, permission);
@Injectable()
export class DocumentAccessGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly documentsService: DocumentsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermission = this.reflector.getAllAndOverride<'VIEW' | 'EDIT' | 'ADMIN'>(
DOCUMENT_PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermission) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
const documentId = request.params.id;
if (!documentId) {
return true;
}
const hasAccess = await this.documentsService.canAccess(documentId, {
userId: user.id,
tenantId: user.tenantId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
isManager: user.isManager || false,
permissions: user.permissions || [],
}, requiredPermission);
if (!hasAccess) {
throw new ForbiddenException('You do not have access to this document');
}
return true;
}
}Unit Tests
// apps/api/src/modules/documents/documents.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { DocumentsController } from './documents.controller';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';
import { DocumentVisibility, DocumentType } from '@prisma/client';
describe('DocumentsController', () => {
let controller: DocumentsController;
let repository: jest.Mocked<DocumentsRepository>;
let service: jest.Mocked<DocumentsService>;
const mockUser = {
id: 'user-1',
tenantId: 'tenant-1',
employeeId: 'emp-1',
teamIds: ['team-1'],
departmentIds: ['dept-1'],
isManager: false,
permissions: ['documents:create', 'documents:read'],
};
const mockDocument = {
id: 'doc-1',
title: 'Test Document',
description: 'Test description',
type: DocumentType.CONTRACT,
visibility: DocumentVisibility.PRIVATE,
fileUrl: 'https://storage.example.com/doc.pdf',
fileName: 'doc.pdf',
mimeType: 'application/pdf',
fileSize: 1024,
tenantId: 'tenant-1',
uploadedById: 'user-1',
employeeId: null,
folderId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DocumentsController],
providers: [
{
provide: DocumentsRepository,
useValue: {
create: jest.fn(),
findById: jest.fn(),
findAccessible: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
grantAccess: jest.fn(),
revokeAccess: jest.fn(),
syncTags: jest.fn(),
},
},
{
provide: DocumentsService,
useValue: {
canAccess: jest.fn(),
getDocumentWithPermissions: jest.fn(),
updateVisibility: jest.fn(),
},
},
],
}).compile();
controller = module.get<DocumentsController>(DocumentsController);
repository = module.get(DocumentsRepository);
service = module.get(DocumentsService);
});
describe('create', () => {
it('should create a document', async () => {
const dto = {
title: 'New Document',
type: DocumentType.CONTRACT,
fileUrl: 'https://storage.example.com/new.pdf',
fileName: 'new.pdf',
mimeType: 'application/pdf',
fileSize: 2048,
};
repository.create.mockResolvedValue(mockDocument);
repository.findById.mockResolvedValue(mockDocument);
const result = await controller.create(mockUser, dto as any);
expect(repository.create).toHaveBeenCalled();
expect(result).toEqual(mockDocument);
});
});
describe('findAll', () => {
it('should return accessible documents with pagination', async () => {
repository.findAccessible.mockResolvedValue({
documents: [mockDocument],
total: 1,
});
const result = await controller.findAll(mockUser, undefined, undefined, undefined, undefined, '1', '20');
expect(result.data).toHaveLength(1);
expect(result.meta.total).toBe(1);
expect(result.meta.page).toBe(1);
});
});
describe('findOne', () => {
it('should return document with permissions', async () => {
const documentWithPermissions = {
...mockDocument,
canEdit: true,
canDelete: true,
canShare: true,
};
service.getDocumentWithPermissions.mockResolvedValue(documentWithPermissions);
const result = await controller.findOne(mockUser, 'doc-1');
expect(result.canEdit).toBe(true);
expect(result.canDelete).toBe(true);
});
});
describe('update', () => {
it('should update document when user has edit access', async () => {
service.canAccess.mockResolvedValue(true);
repository.update.mockResolvedValue(mockDocument);
repository.findById.mockResolvedValue(mockDocument);
const result = await controller.update(mockUser, 'doc-1', { title: 'Updated Title' });
expect(repository.update).toHaveBeenCalledWith('doc-1', 'tenant-1', { title: 'Updated Title' });
});
it('should throw when user lacks edit access', async () => {
service.canAccess.mockResolvedValue(false);
await expect(controller.update(mockUser, 'doc-1', { title: 'Updated' }))
.rejects.toThrow('You cannot edit this document');
});
});
describe('delete', () => {
it('should delete document when user is owner', async () => {
service.canAccess.mockResolvedValue(false);
repository.findById.mockResolvedValue(mockDocument); // uploadedById matches user.id
await controller.delete(mockUser, 'doc-1');
expect(repository.delete).toHaveBeenCalledWith('doc-1', 'tenant-1');
});
it('should throw when user cannot delete', async () => {
service.canAccess.mockResolvedValue(false);
repository.findById.mockResolvedValue({ ...mockDocument, uploadedById: 'other-user' });
await expect(controller.delete(mockUser, 'doc-1'))
.rejects.toThrow('You cannot delete this document');
});
});
});Visibility Rules Summary
| Visibility | Who Can View |
|---|---|
| PRIVATE | Only the document owner |
| TEAM | Owner + users in same team as document's employee |
| DEPARTMENT | Owner + users in same department |
| MANAGERS | Owner + all users with manager role |
| COMPANY | All users in tenant |
| CUSTOM | Owner + users with explicit access grants |
Key Implementation Notes
- Visibility Check Order: Owner check → Explicit access → Visibility-based access
- Permission Hierarchy: VIEW < EDIT < ADMIN - higher permissions include lower ones
- Tenant Isolation: All queries filter by
tenantIdfirst - Access Grants: Custom visibility uses
DocumentAccesstable for fine-grained control - Tag Integration: Documents can be tagged using the Tag system (see Reference Implementation - Tags)