Phase 06: Document Management
Document management with visibility levels, access control, and Google Cloud Storage integration
Phase 06: Document Management
Goal: Build a document management system with upload to Google Cloud Storage, visibility levels (private, team, department, company), fine-grained access control, and sharing capabilities.
| Attribute | Value |
|---|---|
| Steps | 93-108 |
| Estimated Time | 10-14 hours |
| Dependencies | Phase 05 complete (TanStack Query, API helper, CurrentUser decorator available) |
| Completion Gate | Users can upload documents, set visibility, share with specific users/teams, access control enforced |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 93 | Add DocumentCategory enum | 10 min |
| 94 | Add DocumentVisibility enum | 10 min |
| 95 | Add AccessType enum | 10 min |
| 96 | Add DocumentPermission, DocumentStatus enums | 10 min |
| 97 | Add Document model | 20 min |
| 98 | Add DocumentAccess model | 15 min |
| 99 | Run migration | 15 min |
| 100 | Setup GCS configuration | 30 min |
| 101 | Create upload service | 35 min |
| 102 | Create DocumentRepository | 30 min |
| 103 | Create DocumentAccessService | 35 min |
| 104 | Create DocumentService | 40 min |
| 105 | Create DocumentController | 35 min |
| 106 | Create document upload page | 40 min |
| 107 | Create document list page | 35 min |
| 108 | Create share document modal | 30 min |
Phase Context (READ FIRST)
What This Phase Accomplishes
- Document upload to Google Cloud Storage
- Signed URL generation for secure downloads
- Visibility levels: PRIVATE, TEAM, DEPARTMENT, MANAGERS, COMPANY, CUSTOM
- Fine-grained access control with VIEW, DOWNLOAD, EDIT permissions
- Sharing with specific users, teams, or departments
- Optional expiration dates on shared access
- Document categories for organization
What This Phase Does NOT Include
- Document versioning - single version only
- Full-text search - future enhancement with AI
- Document preview/thumbnails - download only
- Folder/hierarchy structure - flat organization with categories
- Document editing - view/download only
- Collaborative editing - not in scope
Visibility Rules
| Visibility | Who Can VIEW | Who Can DOWNLOAD/EDIT |
|---|---|---|
| PRIVATE | Owner only | Owner only |
| TEAM | Owner's teammates | Requires explicit DocumentAccess grant |
| DEPARTMENT | Owner's department | Requires explicit DocumentAccess grant |
| MANAGERS | All managers in tenant | Requires explicit DocumentAccess grant |
| COMPANY | Everyone in tenant | Requires explicit DocumentAccess grant |
| CUSTOM | Explicit grants only | Explicit grants only |
Note: Visibility grants VIEW access. DOWNLOAD/EDIT always require explicit DocumentAccess grants.
Bluewoo Anti-Pattern Reminder
This phase intentionally has NO:
- Complex permission engines (Casbin, CASL) - simple access matrix
- Separate file service microservice - integrated into main API
- S3-compatible abstraction layers - direct GCS SDK usage
- Document workflow/approval - simple CRUD
- Real-time collaboration - static documents
If the AI suggests adding any of these, REJECT and continue with the spec.
Step 93: Add DocumentCategory Enum
Input
- Phase 05 complete
- Prisma schema at
packages/database/prisma/schema.prisma
Constraints
- DO NOT modify existing models
- ONLY add the enum definition
- Use exact values from database-schema.mdx
Task
Add to packages/database/prisma/schema.prisma:
// ==========================================
// DOCUMENT ENUMS
// ==========================================
enum DocumentCategory {
POLICY
HANDBOOK
CONTRACT
PERFORMANCE
TRAINING
CERTIFICATE
PERSONAL
OTHER
}Gate
cd packages/database
npx prisma format
# Should complete without errors
cat prisma/schema.prisma | grep -A 12 "enum DocumentCategory"
# Should show all enum valuesCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum already exists | Duplicate definition | Remove duplicate |
Invalid enum value | Typo in value | Check spelling matches spec |
Rollback
# Remove the enum block from schema.prismaLock
packages/database/prisma/schema.prisma (DocumentCategory enum)Checkpoint
- DocumentCategory enum added
- prisma format succeeds
Step 94: Add DocumentVisibility Enum
Input
- Step 93 complete
- DocumentCategory enum exists
Constraints
- DO NOT modify DocumentCategory enum
- ONLY add DocumentVisibility enum
Task
Add to packages/database/prisma/schema.prisma (after DocumentCategory):
enum DocumentVisibility {
PRIVATE // Only document owner
TEAM // Owner's team(s)
DEPARTMENT // Owner's department(s)
MANAGERS // All employees with manager role
COMPANY // All employees in tenant
CUSTOM // Use DocumentAccess table for explicit grants
}Gate
cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 8 "enum DocumentVisibility"Rollback
# Remove DocumentVisibility enum blockLock
packages/database/prisma/schema.prisma (DocumentVisibility enum)Checkpoint
- DocumentVisibility enum added
- prisma format succeeds
Step 95: Add AccessType Enum
Input
- Step 94 complete
Constraints
- ONLY add AccessType enum
Task
Add to packages/database/prisma/schema.prisma:
enum AccessType {
USER
TEAM
DEPARTMENT
ROLE
}Gate
cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 6 "enum AccessType"Rollback
# Remove AccessType enum blockLock
packages/database/prisma/schema.prisma (AccessType enum)Checkpoint
- AccessType enum added
- prisma format succeeds
Step 96: Add DocumentPermission and DocumentStatus Enums
Input
- Step 95 complete
Constraints
- ONLY add the two enums
- DocumentStatus is required for soft delete support
Task
Add to packages/database/prisma/schema.prisma:
enum DocumentPermission {
VIEW
DOWNLOAD
EDIT
}
enum DocumentStatus {
ACTIVE
ARCHIVED
DELETED
}Gate
cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 5 "enum DocumentPermission"
cat prisma/schema.prisma | grep -A 5 "enum DocumentStatus"Rollback
# Remove both enum blocksLock
packages/database/prisma/schema.prisma (DocumentPermission, DocumentStatus enums)Checkpoint
- DocumentPermission enum added
- DocumentStatus enum added
- prisma format succeeds
Step 97: Add Document Model
Input
- Steps 93-96 complete (all document enums exist)
Constraints
- Create Document model WITHOUT DocumentAccess relation first
- Will add relation in Step 98 after DocumentAccess exists
- Include tenantId for multi-tenant isolation
Task
Add to packages/database/prisma/schema.prisma:
// ==========================================
// DOCUMENT MODELS
// ==========================================
model Document {
id String @id @default(cuid())
tenantId String
employeeId String? // Optional link to employee record
uploadedById String // Who uploaded
title String
description String?
category DocumentCategory
fileUrl String // GCS URL
fileName String // Original filename
mimeType String
fileSize Int // Bytes
visibility DocumentVisibility @default(PRIVATE)
vectorId String? // For future AI search
indexed Boolean @default(false)
status DocumentStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, category])
@@index([tenantId, visibility])
@@index([tenantId, uploadedById])
@@map("documents")
}Also update the Tenant model to add the relation:
// Add to Tenant model
documents Document[]Gate
cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 25 "model Document {"Rollback
# Remove Document model and Tenant relationLock
packages/database/prisma/schema.prisma (Document model)Checkpoint
- Document model added with all fields
- Tenant relation added
- prisma format succeeds
Step 98: Add DocumentAccess Model
Input
- Step 97 complete
- Document model exists
Constraints
- Add DocumentAccess model
- Add relation arrays to Document model
Task
Add to packages/database/prisma/schema.prisma:
model DocumentAccess {
id String @id @default(cuid())
documentId String
accessType AccessType
targetId String? // userId, teamId, departmentId, or roleId
permission DocumentPermission @default(VIEW)
grantedBy String
grantedAt DateTime @default(now())
expiresAt DateTime?
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@index([documentId])
@@index([targetId])
@@map("document_access")
}Update the Document model to add the relation array:
// Add to Document model (after tenant relation)
accessGrants DocumentAccess[]Gate
cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 15 "model DocumentAccess {"
cat prisma/schema.prisma | grep "accessGrants"Rollback
# Remove DocumentAccess model and accessGrants relationLock
packages/database/prisma/schema.prisma (DocumentAccess model)Checkpoint
- DocumentAccess model added
- accessGrants relation added to Document
- prisma format succeeds
Step 99: Run Migration
Input
- Steps 97-98 complete
- All document models defined
Constraints
- Migration name must be descriptive
- Verify tables created correctly
Task
cd packages/database
npx prisma migrate dev --name add_document_managementGate
cd packages/database
npx prisma studio
# Open browser, verify Document and DocumentAccess tables exist
# Alternative: check migration file exists
ls -la prisma/migrations/*add_document_management*Common Errors
| Error | Cause | Fix |
|---|---|---|
Foreign key constraint failed | Missing Tenant relation | Add documents to Tenant model |
Enum not found | Enum not defined | Add missing enum from Steps 93-96 |
Column already exists | Duplicate migration | Reset and re-run migration |
Rollback
cd packages/database
npx prisma migrate reset
# Then re-run migrationsLock
packages/database/prisma/migrations/*add_document_management*Checkpoint
- Migration completed successfully
- Document table exists
- DocumentAccess table exists
- Prisma Client regenerated
Step 100: Setup GCS Configuration
Input
- Step 99 complete
- Database tables exist
Constraints
- Use Google Cloud Storage (not S3)
- Configuration via environment variables
- Support for development (local) and production (cloud)
Task
First, install the GCS SDK:
cd apps/api
npm install @google-cloud/storageCreate environment variables in .env.example and .env:
# apps/api/.env
GCS_PROJECT_ID=your-project-id
GCS_BUCKET_NAME=hrms-documents
GCS_KEY_FILE=./gcs-service-account.json
# For local development, you can use a service account key file
# For production, use workload identity or default credentialsCreate the storage configuration:
// apps/api/src/documents/storage.config.ts
import { Storage } from '@google-cloud/storage';
export interface StorageConfig {
projectId: string;
bucketName: string;
keyFilename?: string;
}
export function getStorageConfig(): StorageConfig {
return {
projectId: process.env.GCS_PROJECT_ID || '',
bucketName: process.env.GCS_BUCKET_NAME || 'hrms-documents',
keyFilename: process.env.GCS_KEY_FILE,
};
}
export function createStorageClient(): Storage {
const config = getStorageConfig();
if (config.keyFilename) {
return new Storage({
projectId: config.projectId,
keyFilename: config.keyFilename,
});
}
// Use default credentials (for Cloud Run, GKE, etc.)
return new Storage({
projectId: config.projectId,
});
}Create the module directories:
mkdir -p apps/api/src/documents/repositories
mkdir -p apps/api/src/documents/services
mkdir -p apps/api/src/documents/controllers
mkdir -p apps/api/src/documents/dtoGate
cd apps/api
cat src/documents/storage.config.ts
# Should show storage configuration
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@google-cloud/storage' | Package not installed | Run npm install @google-cloud/storage |
Authentication failed | Invalid service account | Check GCS_KEY_FILE path and permissions |
Rollback
rm -rf apps/api/src/documents
npm uninstall @google-cloud/storageLock
apps/api/src/documents/storage.config.tsCheckpoint
- @google-cloud/storage installed
- storage.config.ts created
- Environment variables documented
- Directory structure created
Step 101: Create Upload Service
Input
- Step 100 complete
- GCS configuration exists
Constraints
- Upload files to GCS with unique names
- Generate signed URLs for downloads
- Support file size limits
- Validate mime types
Task
Create apps/api/src/documents/services/upload.service.ts:
import { Injectable, BadRequestException } from '@nestjs/common';
import { createStorageClient, getStorageConfig } from '../storage.config';
import { v4 as uuidv4 } from 'uuid';
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_MIME_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/png',
'image/jpeg',
'text/plain',
];
export interface UploadResult {
fileUrl: string;
fileName: string;
mimeType: string;
fileSize: number;
}
@Injectable()
export class UploadService {
private storage = createStorageClient();
private config = getStorageConfig();
async uploadFile(
file: Express.Multer.File,
tenantId: string,
): Promise<UploadResult> {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
throw new BadRequestException(
`File size exceeds maximum allowed (${MAX_FILE_SIZE / 1024 / 1024}MB)`,
);
}
// Validate mime type
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException(
`File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`,
);
}
// Generate unique filename
const ext = file.originalname.split('.').pop();
const uniqueName = `${tenantId}/${uuidv4()}.${ext}`;
const bucket = this.storage.bucket(this.config.bucketName);
const blob = bucket.file(uniqueName);
// Upload to GCS
await blob.save(file.buffer, {
metadata: {
contentType: file.mimetype,
metadata: {
originalName: file.originalname,
tenantId,
},
},
});
// Get the public URL (or use signed URLs for private access)
const fileUrl = `gs://${this.config.bucketName}/${uniqueName}`;
return {
fileUrl,
fileName: file.originalname,
mimeType: file.mimetype,
fileSize: file.size,
};
}
async getSignedUrl(fileUrl: string, expiresInMinutes = 60): Promise<string> {
// Extract path from gs:// URL
const path = fileUrl.replace(`gs://${this.config.bucketName}/`, '');
const bucket = this.storage.bucket(this.config.bucketName);
const blob = bucket.file(path);
const [signedUrl] = await blob.getSignedUrl({
version: 'v4',
action: 'read',
expires: Date.now() + expiresInMinutes * 60 * 1000,
});
return signedUrl;
}
async deleteFile(fileUrl: string): Promise<void> {
const path = fileUrl.replace(`gs://${this.config.bucketName}/`, '');
const bucket = this.storage.bucket(this.config.bucketName);
const blob = bucket.file(path);
await blob.delete();
}
}Install uuid if not already installed:
cd apps/api
npm install uuid
npm install -D @types/uuidGate
cd apps/api
cat src/documents/services/upload.service.ts
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module 'uuid' | Package not installed | Run npm install uuid |
Property 'buffer' does not exist | Multer types missing | Install @types/multer |
Rollback
rm apps/api/src/documents/services/upload.service.tsLock
apps/api/src/documents/services/upload.service.tsCheckpoint
- UploadService created
- File validation implemented
- Signed URL generation works
- TypeScript compiles
Step 102: Create DocumentRepository
Input
- Step 101 complete
- Document model exists in database
Constraints
- All queries must filter by tenantId
- Support visibility-based filtering
- Use Prisma for database operations
Task
Create apps/api/src/documents/repositories/document.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
Prisma,
Document,
DocumentCategory,
DocumentVisibility,
DocumentStatus,
} from '@prisma/client';
export interface DocumentWithAccess extends Document {
accessGrants?: {
id: string;
accessType: string;
targetId: string | null;
permission: string;
expiresAt: Date | null;
}[];
}
export interface DocumentQueryOptions {
category?: DocumentCategory;
visibility?: DocumentVisibility;
status?: DocumentStatus;
uploadedById?: string;
search?: string;
page?: number;
limit?: number;
}
@Injectable()
export class DocumentRepository {
constructor(private prisma: PrismaService) {}
async create(
data: Prisma.DocumentCreateInput,
): Promise<Document> {
return this.prisma.document.create({ data });
}
async findById(
tenantId: string,
id: string,
): Promise<DocumentWithAccess | null> {
return this.prisma.document.findFirst({
where: {
id,
tenantId,
status: { not: 'DELETED' },
},
include: {
accessGrants: {
where: {
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
},
},
});
}
async findByTenant(
tenantId: string,
options: DocumentQueryOptions = {},
): Promise<{ items: Document[]; total: number }> {
const {
category,
visibility,
status = 'ACTIVE',
uploadedById,
search,
page = 1,
limit = 20,
} = options;
const where: Prisma.DocumentWhereInput = {
tenantId,
status,
...(category && { category }),
...(visibility && { visibility }),
...(uploadedById && { uploadedById }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ fileName: { contains: search, mode: 'insensitive' } },
],
}),
};
const [items, total] = await Promise.all([
this.prisma.document.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.document.count({ where }),
]);
return { items, total };
}
async findAccessibleByUser(
tenantId: string,
userId: string,
userTeamIds: string[],
userDepartmentIds: string[],
userRoleIds: string[],
isManager: boolean,
options: DocumentQueryOptions = {},
): Promise<{ items: Document[]; total: number }> {
const {
category,
search,
page = 1,
limit = 20,
} = options;
// Pre-fetch dependent data (can't await inside array literal)
const teamUploaderIds = userTeamIds.length > 0
? await this.getUploadersInTeams(tenantId, userTeamIds)
: [];
const deptUploaderIds = userDepartmentIds.length > 0
? await this.getUploadersInDepartments(tenantId, userDepartmentIds)
: [];
// Build visibility conditions
const visibilityConditions: Prisma.DocumentWhereInput[] = [
// Documents uploaded by the user
{ uploadedById: userId },
// COMPANY visibility - all can see
{ visibility: 'COMPANY' },
// MANAGERS visibility - if user is a manager
...(isManager ? [{ visibility: 'MANAGERS' as DocumentVisibility }] : []),
// TEAM visibility - if user is in the same team as uploader
...(teamUploaderIds.length > 0 ? [{
visibility: 'TEAM' as DocumentVisibility,
uploadedById: { in: teamUploaderIds },
}] : []),
// DEPARTMENT visibility - if user is in the same department as uploader
...(deptUploaderIds.length > 0 ? [{
visibility: 'DEPARTMENT' as DocumentVisibility,
uploadedById: { in: deptUploaderIds },
}] : []),
// CUSTOM visibility - check DocumentAccess grants
{
visibility: 'CUSTOM',
accessGrants: {
some: {
AND: [
{
OR: [
{ accessType: 'USER', targetId: userId },
{ accessType: 'TEAM', targetId: { in: userTeamIds } },
{ accessType: 'DEPARTMENT', targetId: { in: userDepartmentIds } },
{ accessType: 'ROLE', targetId: { in: userRoleIds } },
],
},
{
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
],
},
},
},
];
// Build the main where clause
// Note: When using OR for visibility AND search, we need to structure carefully
const baseWhere: Prisma.DocumentWhereInput = {
tenantId,
status: 'ACTIVE',
OR: visibilityConditions,
...(category && { category }),
};
// If search is provided, add it as an AND condition
const where: Prisma.DocumentWhereInput = search
? {
AND: [
baseWhere,
{
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
},
],
}
: baseWhere;
const [items, total] = await Promise.all([
this.prisma.document.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.document.count({ where }),
]);
return { items, total };
}
private async getUploadersInTeams(
tenantId: string,
teamIds: string[],
): Promise<string[]> {
// Find all employees in the given teams, return their associated user IDs
// Note: This assumes Employee has a relation to User via some identifier
// The uploadedById in Document should match the authenticated user's ID
const teamMembers = await this.prisma.employeeTeam.findMany({
where: { teamId: { in: teamIds } },
select: {
employee: {
select: { id: true, email: true }
}
},
});
// Return employee IDs as uploader identifiers
// In production, this should map to the user ID from your auth system
return teamMembers.map((tm) => tm.employee.id);
}
private async getUploadersInDepartments(
tenantId: string,
departmentIds: string[],
): Promise<string[]> {
const deptMembers = await this.prisma.employeeDepartment.findMany({
where: { departmentId: { in: departmentIds } },
select: {
employee: {
select: { id: true, email: true }
}
},
});
// Return employee IDs as uploader identifiers
return deptMembers.map((dm) => dm.employee.id);
}
async update(
tenantId: string,
id: string,
data: Prisma.DocumentUpdateInput,
): Promise<Document> {
// Use updateMany to enforce tenant isolation
const result = await this.prisma.document.updateMany({
where: { id, tenantId },
data,
});
if (result.count === 0) {
throw new NotFoundException(`Document ${id} not found`);
}
return this.findById(tenantId, id);
}
async softDelete(tenantId: string, id: string): Promise<void> {
// Use updateMany to enforce tenant isolation
const result = await this.prisma.document.updateMany({
where: { id, tenantId },
data: { status: 'DELETED' },
});
if (result.count === 0) {
throw new NotFoundException(`Document ${id} not found`);
}
}
}Gate
cd apps/api
cat src/documents/repositories/document.repository.ts
npx tsc --noEmitRollback
rm apps/api/src/documents/repositories/document.repository.tsLock
apps/api/src/documents/repositories/document.repository.tsCheckpoint
- DocumentRepository created
- Tenant isolation implemented
- Visibility-based queries work
- TypeScript compiles
Step 103: Create DocumentAccessService
Input
- Step 102 complete
- DocumentRepository exists
Constraints
- Check permissions based on visibility and access grants
- Support VIEW, DOWNLOAD, EDIT permission levels
- Handle expired access grants
Task
Create apps/api/src/documents/services/document-access.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
Document,
DocumentAccess,
DocumentPermission,
DocumentVisibility,
AccessType,
} from '@prisma/client';
export interface UserContext {
userId: string;
employeeId: string;
teamIds: string[];
departmentIds: string[];
roleIds: string[];
isManager: boolean;
}
@Injectable()
export class DocumentAccessService {
constructor(private prisma: PrismaService) {}
async canView(
document: Document,
user: UserContext,
): Promise<boolean> {
return this.checkPermission(document, user, 'VIEW');
}
async canDownload(
document: Document,
user: UserContext,
): Promise<boolean> {
return this.checkPermission(document, user, 'DOWNLOAD');
}
async canEdit(
document: Document,
user: UserContext,
): Promise<boolean> {
return this.checkPermission(document, user, 'EDIT');
}
async canDelete(
document: Document,
user: UserContext,
): Promise<boolean> {
// Only the uploader can delete
return document.uploadedById === user.userId;
}
async canShare(
document: Document,
user: UserContext,
): Promise<boolean> {
// Only the uploader or users with EDIT permission can share
if (document.uploadedById === user.userId) {
return true;
}
return this.checkPermission(document, user, 'EDIT');
}
private async checkPermission(
document: Document,
user: UserContext,
requiredPermission: DocumentPermission,
): Promise<boolean> {
// Owner always has all permissions
if (document.uploadedById === user.userId) {
return true;
}
// Check visibility-based access
const hasVisibilityAccess = await this.checkVisibilityAccess(
document,
user,
);
if (hasVisibilityAccess) {
// Visibility-based access grants VIEW permission by default
// DOWNLOAD and EDIT require explicit grants for non-owners
if (requiredPermission === 'VIEW') {
return true;
}
}
// Check explicit access grants (for CUSTOM visibility or higher permissions)
if (document.visibility === 'CUSTOM' || requiredPermission !== 'VIEW') {
return this.checkExplicitAccess(document.id, user, requiredPermission);
}
return hasVisibilityAccess;
}
private async checkVisibilityAccess(
document: Document,
user: UserContext,
): Promise<boolean> {
switch (document.visibility) {
case 'PRIVATE':
return false; // Only owner, handled above
case 'COMPANY':
return true; // All employees in tenant can view
case 'MANAGERS':
return user.isManager;
case 'TEAM':
// Check if user is in any of the uploader's teams
return this.isInSameTeam(document.uploadedById, user.teamIds);
case 'DEPARTMENT':
// Check if user is in any of the uploader's departments
return this.isInSameDepartment(document.uploadedById, user.departmentIds);
case 'CUSTOM':
return false; // Must check explicit grants
default:
return false;
}
}
private async isInSameTeam(
uploaderId: string,
userTeamIds: string[],
): Promise<boolean> {
if (userTeamIds.length === 0) return false;
// uploaderId is the authenticated user's ID (from session)
// We need to find if the uploader's employee record shares a team with the current user
const uploaderTeams = await this.prisma.employeeTeam.findMany({
where: {
employee: { id: uploaderId }, // Using employee ID as uploader identifier
teamId: { in: userTeamIds },
},
});
return uploaderTeams.length > 0;
}
private async isInSameDepartment(
uploaderId: string,
userDepartmentIds: string[],
): Promise<boolean> {
if (userDepartmentIds.length === 0) return false;
const uploaderDepts = await this.prisma.employeeDepartment.findMany({
where: {
employee: { id: uploaderId }, // Using employee ID as uploader identifier
departmentId: { in: userDepartmentIds },
},
});
return uploaderDepts.length > 0;
}
private async checkExplicitAccess(
documentId: string,
user: UserContext,
requiredPermission: DocumentPermission,
): Promise<boolean> {
const permissionHierarchy: DocumentPermission[] = ['VIEW', 'DOWNLOAD', 'EDIT'];
const requiredIndex = permissionHierarchy.indexOf(requiredPermission);
const grants = await this.prisma.documentAccess.findMany({
where: {
documentId,
AND: [
{
OR: [
{ accessType: 'USER', targetId: user.userId },
{ accessType: 'TEAM', targetId: { in: user.teamIds } },
{ accessType: 'DEPARTMENT', targetId: { in: user.departmentIds } },
{ accessType: 'ROLE', targetId: { in: user.roleIds } },
],
},
{
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
],
},
});
// Check if any grant meets the required permission level
return grants.some((grant) => {
const grantIndex = permissionHierarchy.indexOf(grant.permission);
return grantIndex >= requiredIndex;
});
}
async grantAccess(
documentId: string,
accessType: AccessType,
targetId: string,
permission: DocumentPermission,
grantedBy: string,
expiresAt?: Date,
): Promise<DocumentAccess> {
// Check if grant already exists
const existing = await this.prisma.documentAccess.findFirst({
where: {
documentId,
accessType,
targetId,
},
});
if (existing) {
// Update existing grant
return this.prisma.documentAccess.update({
where: { id: existing.id },
data: { permission, expiresAt },
});
}
// Create new grant
return this.prisma.documentAccess.create({
data: {
documentId,
accessType,
targetId,
permission,
grantedBy,
expiresAt,
},
});
}
async revokeAccess(
documentId: string,
accessType: AccessType,
targetId: string,
): Promise<void> {
await this.prisma.documentAccess.deleteMany({
where: {
documentId,
accessType,
targetId,
},
});
}
async getAccessGrants(documentId: string): Promise<DocumentAccess[]> {
return this.prisma.documentAccess.findMany({
where: {
documentId,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
});
}
}Gate
cd apps/api
cat src/documents/services/document-access.service.ts
npx tsc --noEmitRollback
rm apps/api/src/documents/services/document-access.service.tsLock
apps/api/src/documents/services/document-access.service.tsCheckpoint
- DocumentAccessService created
- Permission checking implemented
- Grant/revoke access works
- TypeScript compiles
Step 104: Create DocumentService
Input
- Steps 101-103 complete
- UploadService, DocumentRepository, DocumentAccessService exist
Constraints
- Combine all document operations
- Enforce access control on all operations
- Support CRUD operations
Task
Create apps/api/src/documents/services/document.service.ts:
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { DocumentRepository } from '../repositories/document.repository';
import { DocumentAccessService, UserContext } from './document-access.service';
import { UploadService } from './upload.service';
import {
Document,
DocumentCategory,
DocumentVisibility,
DocumentPermission,
AccessType,
} from '@prisma/client';
export interface CreateDocumentDto {
title: string;
description?: string;
category: DocumentCategory;
visibility: DocumentVisibility;
employeeId?: string;
}
export interface UpdateDocumentDto {
title?: string;
description?: string;
category?: DocumentCategory;
visibility?: DocumentVisibility;
}
export interface ShareDocumentDto {
accessType: AccessType;
targetId: string;
permission: DocumentPermission;
expiresAt?: Date;
}
@Injectable()
export class DocumentService {
constructor(
private documentRepository: DocumentRepository,
private accessService: DocumentAccessService,
private uploadService: UploadService,
) {}
async upload(
tenantId: string,
employeeId: string, // Must be Employee.id, not User.id (for org lookups)
file: Express.Multer.File,
dto: CreateDocumentDto,
): Promise<Document> {
// Upload file to GCS
const uploadResult = await this.uploadService.uploadFile(file, tenantId);
// Create document record
// Note: uploadedById stores Employee.id for TEAM/DEPARTMENT visibility checks
return this.documentRepository.create({
tenant: { connect: { id: tenantId } },
uploadedById: employeeId,
title: dto.title,
description: dto.description,
category: dto.category,
visibility: dto.visibility,
employeeId: dto.employeeId,
fileUrl: uploadResult.fileUrl,
fileName: uploadResult.fileName,
mimeType: uploadResult.mimeType,
fileSize: uploadResult.fileSize,
});
}
async findById(
tenantId: string,
id: string,
user: UserContext,
): Promise<Document> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canView = await this.accessService.canView(document, user);
if (!canView) {
throw new ForbiddenException('You do not have access to this document');
}
return document;
}
async findAccessible(
tenantId: string,
user: UserContext,
options: {
category?: DocumentCategory;
search?: string;
page?: number;
limit?: number;
} = {},
): Promise<{ items: Document[]; total: number; page: number; limit: number; totalPages: number }> {
const { page = 1, limit = 20 } = options;
const result = await this.documentRepository.findAccessibleByUser(
tenantId,
user.userId,
user.teamIds,
user.departmentIds,
user.roleIds,
user.isManager,
options,
);
return {
...result,
page,
limit,
totalPages: Math.ceil(result.total / limit),
};
}
async findMyDocuments(
tenantId: string,
userId: string,
options: {
category?: DocumentCategory;
search?: string;
page?: number;
limit?: number;
} = {},
): Promise<{ items: Document[]; total: number; page: number; limit: number; totalPages: number }> {
const { page = 1, limit = 20 } = options;
const result = await this.documentRepository.findByTenant(tenantId, {
...options,
uploadedById: userId,
});
return {
...result,
page,
limit,
totalPages: Math.ceil(result.total / limit),
};
}
async getDownloadUrl(
tenantId: string,
id: string,
user: UserContext,
): Promise<string> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canDownload = await this.accessService.canDownload(document, user);
if (!canDownload) {
throw new ForbiddenException('You do not have download access to this document');
}
return this.uploadService.getSignedUrl(document.fileUrl);
}
async update(
tenantId: string,
id: string,
user: UserContext,
dto: UpdateDocumentDto,
): Promise<Document> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canEdit = await this.accessService.canEdit(document, user);
if (!canEdit) {
throw new ForbiddenException('You do not have edit access to this document');
}
return this.documentRepository.update(tenantId, id, dto);
}
async delete(
tenantId: string,
id: string,
user: UserContext,
): Promise<void> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canDelete = await this.accessService.canDelete(document, user);
if (!canDelete) {
throw new ForbiddenException('You do not have permission to delete this document');
}
// Soft delete
await this.documentRepository.softDelete(tenantId, id);
}
async share(
tenantId: string,
id: string,
user: UserContext,
dto: ShareDocumentDto,
): Promise<void> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canShare = await this.accessService.canShare(document, user);
if (!canShare) {
throw new ForbiddenException('You do not have permission to share this document');
}
// If sharing with CUSTOM visibility, update document visibility
if (document.visibility !== 'CUSTOM') {
await this.documentRepository.update(tenantId, id, {
visibility: 'CUSTOM',
});
}
await this.accessService.grantAccess(
id,
dto.accessType,
dto.targetId,
dto.permission,
user.userId,
dto.expiresAt,
);
}
async unshare(
tenantId: string,
id: string,
user: UserContext,
accessType: AccessType,
targetId: string,
): Promise<void> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canShare = await this.accessService.canShare(document, user);
if (!canShare) {
throw new ForbiddenException('You do not have permission to modify sharing');
}
await this.accessService.revokeAccess(id, accessType, targetId);
}
async getSharing(
tenantId: string,
id: string,
user: UserContext,
): Promise<{ accessGrants: any[] }> {
const document = await this.documentRepository.findById(tenantId, id);
if (!document) {
throw new NotFoundException('Document not found');
}
const canShare = await this.accessService.canShare(document, user);
if (!canShare) {
throw new ForbiddenException('You do not have permission to view sharing settings');
}
const accessGrants = await this.accessService.getAccessGrants(id);
return { accessGrants };
}
}Gate
cd apps/api
cat src/documents/services/document.service.ts
npx tsc --noEmitRollback
rm apps/api/src/documents/services/document.service.tsLock
apps/api/src/documents/services/document.service.tsCheckpoint
- DocumentService created
- All CRUD operations implemented
- Access control enforced
- Sharing functionality works
- TypeScript compiles
Step 105: Create DocumentController
Input
- Step 104 complete
- DocumentService exists
Constraints
- Use TenantId decorator for tenant isolation
- Use CurrentUser decorator for user context
- Support file uploads with Multer
Task
First, create DTOs in apps/api/src/documents/dto/:
// apps/api/src/documents/dto/create-document.dto.ts
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { DocumentCategory, DocumentVisibility } from '@prisma/client';
export class CreateDocumentDto {
@IsString()
title: string;
@IsOptional()
@IsString()
description?: string;
@IsEnum(DocumentCategory)
category: DocumentCategory;
@IsEnum(DocumentVisibility)
visibility: DocumentVisibility;
@IsOptional()
@IsString()
employeeId?: string;
}// apps/api/src/documents/dto/update-document.dto.ts
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { DocumentCategory, DocumentVisibility } from '@prisma/client';
export class UpdateDocumentDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(DocumentCategory)
category?: DocumentCategory;
@IsOptional()
@IsEnum(DocumentVisibility)
visibility?: DocumentVisibility;
}// apps/api/src/documents/dto/share-document.dto.ts
import { IsString, IsOptional, IsEnum, IsDateString } from 'class-validator';
import { AccessType, DocumentPermission } from '@prisma/client';
export class ShareDocumentDto {
@IsEnum(AccessType)
accessType: AccessType;
@IsString()
targetId: string;
@IsEnum(DocumentPermission)
permission: DocumentPermission;
@IsOptional()
@IsDateString()
expiresAt?: string;
}First, create the module in apps/api/src/documents/documents.module.ts:
// apps/api/src/documents/documents.module.ts
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { PrismaModule } from '../prisma/prisma.module';
import { DocumentRepository } from './repositories/document.repository';
import { UploadService } from './services/upload.service';
import { DocumentAccessService } from './services/document-access.service';
import { DocumentService } from './services/document.service';
import { DocumentController } from './controllers/document.controller';
@Module({
imports: [
PrismaModule,
MulterModule.register({
storage: memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
}),
],
controllers: [DocumentController],
providers: [
DocumentRepository,
UploadService,
DocumentAccessService,
DocumentService,
],
exports: [DocumentService],
})
export class DocumentsModule {}Register the module in apps/api/src/app.module.ts:
// Add to imports in app.module.ts
import { DocumentsModule } from './documents/documents.module';
@Module({
imports: [
// ... existing modules
DocumentsModule,
],
})
export class AppModule {}Create the controller:
// apps/api/src/documents/controllers/document.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { TenantGuard } from '../../tenant/tenant.guard';
import { TenantId } from '../../tenant/tenant.decorator';
import { CurrentUser } from '../../auth/current-user.decorator';
import { DocumentService } from '../services/document.service';
import { CreateDocumentDto } from '../dto/create-document.dto';
import { UpdateDocumentDto } from '../dto/update-document.dto';
import { ShareDocumentDto } from '../dto/share-document.dto';
import { DocumentCategory, AccessType } from '@prisma/client';
@Controller('api/v1/documents')
@UseGuards(TenantGuard)
export class DocumentController {
constructor(private documentService: DocumentService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
async upload(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@UploadedFile() file: Express.Multer.File,
@Body() dto: CreateDocumentDto,
) {
// Pass employeeId (not userId) for TEAM/DEPARTMENT visibility checks
const document = await this.documentService.upload(
tenantId,
user.employeeId,
file,
dto,
);
return { data: document, error: null };
}
@Get()
async list(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Query('category') category?: DocumentCategory,
@Query('search') search?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const userContext = await this.buildUserContext(user);
const result = await this.documentService.findAccessible(
tenantId,
userContext,
{
category,
search,
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 20,
},
);
return { data: result, error: null };
}
@Get('my')
async myDocuments(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Query('category') category?: DocumentCategory,
@Query('search') search?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
// Use employeeId to match uploadedById in documents
const result = await this.documentService.findMyDocuments(
tenantId,
user.employeeId,
{
category,
search,
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 20,
},
);
return { data: result, error: null };
}
@Get(':id')
async findOne(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
const userContext = await this.buildUserContext(user);
const document = await this.documentService.findById(
tenantId,
id,
userContext,
);
return { data: document, error: null };
}
@Get(':id/download')
async download(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
const userContext = await this.buildUserContext(user);
const url = await this.documentService.getDownloadUrl(
tenantId,
id,
userContext,
);
return { data: { url }, error: null };
}
@Patch(':id')
async update(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: UpdateDocumentDto,
) {
const userContext = await this.buildUserContext(user);
const document = await this.documentService.update(
tenantId,
id,
userContext,
dto,
);
return { data: document, error: null };
}
@Delete(':id')
async delete(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
const userContext = await this.buildUserContext(user);
await this.documentService.delete(tenantId, id, userContext);
return { data: { success: true }, error: null };
}
@Post(':id/share')
async share(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: ShareDocumentDto,
) {
const userContext = await this.buildUserContext(user);
await this.documentService.share(tenantId, id, userContext, {
...dto,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
});
return { data: { success: true }, error: null };
}
@Delete(':id/share/:accessType/:targetId')
async unshare(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
@Param('accessType') accessType: AccessType,
@Param('targetId') targetId: string,
) {
const userContext = await this.buildUserContext(user);
await this.documentService.unshare(
tenantId,
id,
userContext,
accessType,
targetId,
);
return { data: { success: true }, error: null };
}
@Get(':id/sharing')
async getSharing(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('id') id: string,
) {
const userContext = await this.buildUserContext(user);
const sharing = await this.documentService.getSharing(
tenantId,
id,
userContext,
);
return { data: sharing, error: null };
}
private async buildUserContext(user: any) {
// MVP: Trust JWT claims for team/department membership
// PRODUCTION NOTE: JWT claims are set at login and may become stale if user
// is reassigned to different teams/departments. For production, consider:
// - Fetching fresh membership from EmployeeTeam/EmployeeDepartment tables
// - Using short-lived tokens with refresh
// - Invalidating tokens on org changes
return {
userId: user.userId,
employeeId: user.employeeId,
teamIds: user.teamIds || [],
departmentIds: user.departmentIds || [],
roleIds: user.roleIds || [],
isManager: user.isManager || false,
};
}
}Note: The
CurrentUserdecorator was created in Phase 01 (Step 23). Import it from../../auth/current-user.decorator. TheTenantGuardandTenantIddecorator are from the tenant module created in Phase 01.
Install required packages:
cd apps/api
npm install @nestjs/platform-express
npm install -D @types/multerGate
cd apps/api
cat src/documents/documents.module.ts
cat src/documents/controllers/document.controller.ts
npx tsc --noEmitCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find CurrentUser decorator | Wrong import path | Import from ../../auth/current-user.decorator |
Cannot find TenantGuard | Wrong import path | Import from ../../tenant/tenant.guard |
Cannot find TenantId | Wrong import path | Import from ../../tenant/tenant.decorator |
FileInterceptor not found | Missing platform-express | Install @nestjs/platform-express |
Module not registered | Missing from AppModule | Add DocumentsModule to app.module.ts imports |
Rollback
rm -rf apps/api/src/documents/controllers
rm -rf apps/api/src/documents/dto
rm apps/api/src/documents/documents.module.ts
# Also remove DocumentsModule from app.module.ts importsLock
apps/api/src/documents/documents.module.ts
apps/api/src/documents/controllers/document.controller.ts
apps/api/src/documents/dto/*Checkpoint
- DocumentsModule created
- DocumentsModule registered in AppModule
- DocumentController created with correct route prefix (
api/v1/documents) - All DTOs created
- Multer configured for memory storage
- File upload endpoint works
- All CRUD endpoints implemented
- Sharing endpoints implemented
- TypeScript compiles
Step 106: Create Document Upload Page
Input
- Step 105 complete
- API endpoints available
Constraints
- Use Shadcn UI components
- Use react-hook-form with zod validation
- Support drag-and-drop file upload
- Show upload progress
Task
First, update apps/web/lib/api.ts to support FormData uploads:
// Update the post method in apps/web/lib/api.ts to handle FormData
async post<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
const tenantId = await getTenantId();
const headers: Record<string, string> = {
'x-tenant-id': tenantId,
};
let processedBody: string | FormData;
if (body instanceof FormData) {
// Don't set Content-Type for FormData - browser sets it with boundary
processedBody = body;
} else {
headers['Content-Type'] = 'application/json';
processedBody = JSON.stringify(body);
}
const res = await fetch(`${API_URL}/api/v1${path}`, {
method: 'POST',
headers,
body: processedBody,
});
if (!res.ok) {
throw new Error(`API request failed: ${res.status}`);
}
return res.json();
}Create the API queries in apps/web/lib/queries/documents.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';
// Define types locally instead of importing from @prisma/client
// This ensures the frontend doesn't depend on Prisma directly
export type DocumentCategory =
| 'POLICY'
| 'HANDBOOK'
| 'CONTRACT'
| 'PERFORMANCE'
| 'TRAINING'
| 'CERTIFICATE'
| 'PERSONAL'
| 'OTHER';
export type DocumentVisibility =
| 'PRIVATE'
| 'TEAM'
| 'DEPARTMENT'
| 'MANAGERS'
| 'COMPANY'
| 'CUSTOM';
export type AccessType = 'USER' | 'TEAM' | 'DEPARTMENT' | 'ROLE';
export type DocumentPermission = 'VIEW' | 'DOWNLOAD' | 'EDIT';
export type DocumentStatus = 'ACTIVE' | 'ARCHIVED' | 'DELETED';
export interface Document {
id: string;
tenantId: string;
employeeId: string | null;
uploadedById: string;
title: string;
description: string | null;
category: DocumentCategory;
fileUrl: string;
fileName: string;
mimeType: string;
fileSize: number;
visibility: DocumentVisibility;
vectorId: string | null;
indexed: boolean;
status: DocumentStatus;
createdAt: string;
updatedAt: string;
}
export interface DocumentAccess {
id: string;
documentId: string;
accessType: AccessType;
targetId: string | null;
permission: DocumentPermission;
grantedBy: string;
grantedAt: string;
expiresAt: string | null;
}
interface DocumentListResponse {
items: Document[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useDocuments(options?: {
category?: DocumentCategory;
search?: string;
page?: number;
limit?: number;
}) {
return useQuery({
queryKey: ['documents', options],
queryFn: async (): Promise<DocumentListResponse> => {
const params = new URLSearchParams();
if (options?.category) params.set('category', options.category);
if (options?.search) params.set('search', options.search);
if (options?.page) params.set('page', options.page.toString());
if (options?.limit) params.set('limit', options.limit.toString());
const response = await api.get<DocumentListResponse>(
`/documents?${params}`,
);
return response.data;
},
});
}
export function useMyDocuments(options?: {
category?: DocumentCategory;
search?: string;
page?: number;
limit?: number;
}) {
return useQuery({
queryKey: ['my-documents', options],
queryFn: async (): Promise<DocumentListResponse> => {
const params = new URLSearchParams();
if (options?.category) params.set('category', options.category);
if (options?.search) params.set('search', options.search);
if (options?.page) params.set('page', options.page.toString());
if (options?.limit) params.set('limit', options.limit.toString());
const response = await api.get<DocumentListResponse>(
`/documents/my?${params}`,
);
return response.data;
},
});
}
export function useDocument(id: string) {
return useQuery({
queryKey: ['document', id],
queryFn: async (): Promise<Document> => {
const response = await api.get<Document>(`/documents/${id}`);
return response.data;
},
enabled: !!id,
});
}
export function useUploadDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: {
file: File;
title: string;
description?: string;
category: DocumentCategory;
visibility: DocumentVisibility;
employeeId?: string;
}) => {
const formData = new FormData();
formData.append('file', data.file);
formData.append('title', data.title);
if (data.description) formData.append('description', data.description);
formData.append('category', data.category);
formData.append('visibility', data.visibility);
if (data.employeeId) formData.append('employeeId', data.employeeId);
const response = await api.post<Document>('/documents', formData);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['my-documents'] });
},
});
}
export function useUpdateDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: {
title?: string;
description?: string;
category?: DocumentCategory;
visibility?: DocumentVisibility;
};
}) => {
const response = await api.patch<Document>(`/documents/${id}`, data);
return response.data;
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['my-documents'] });
queryClient.invalidateQueries({ queryKey: ['document', id] });
},
});
}
export function useDeleteDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/documents/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['my-documents'] });
},
});
}
export function useDownloadDocument() {
return useMutation({
mutationFn: async (id: string) => {
const response = await api.get<{ url: string }>(
`/documents/${id}/download`,
);
return response.data.url;
},
});
}
export function useShareDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: {
accessType: AccessType;
targetId: string;
permission: DocumentPermission;
expiresAt?: string;
};
}) => {
await api.post(`/documents/${id}/share`, data);
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['document', id] });
},
});
}
export function useUnshareDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
accessType,
targetId,
}: {
id: string;
accessType: AccessType;
targetId: string;
}) => {
await api.delete(`/documents/${id}/share/${accessType}/${targetId}`);
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['document', id] });
},
});
}Create the upload form component:
// apps/web/app/dashboard/documents/upload/page.tsx
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDropzone } from 'react-dropzone';
import { Upload, X, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { useUploadDocument } from '@/lib/queries/documents';
const CATEGORIES = [
{ value: 'POLICY', label: 'Policy' },
{ value: 'HANDBOOK', label: 'Handbook' },
{ value: 'CONTRACT', label: 'Contract' },
{ value: 'PERFORMANCE', label: 'Performance' },
{ value: 'TRAINING', label: 'Training' },
{ value: 'CERTIFICATE', label: 'Certificate' },
{ value: 'PERSONAL', label: 'Personal' },
{ value: 'OTHER', label: 'Other' },
] as const;
const VISIBILITIES = [
{ value: 'PRIVATE', label: 'Private', description: 'Only you can see this document' },
{ value: 'TEAM', label: 'Team', description: 'Members of your team(s) can see this document' },
{ value: 'DEPARTMENT', label: 'Department', description: 'Members of your department(s) can see this document' },
{ value: 'MANAGERS', label: 'Managers', description: 'All managers can see this document' },
{ value: 'COMPANY', label: 'Company', description: 'Everyone in the company can see this document' },
] as const;
const uploadSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(1000).optional(),
category: z.enum(['POLICY', 'HANDBOOK', 'CONTRACT', 'PERFORMANCE', 'TRAINING', 'CERTIFICATE', 'PERSONAL', 'OTHER']),
visibility: z.enum(['PRIVATE', 'TEAM', 'DEPARTMENT', 'MANAGERS', 'COMPANY']),
});
type UploadFormData = z.infer<typeof uploadSchema>;
export default function DocumentUploadPage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const uploadMutation = useUploadDocument();
const form = useForm<UploadFormData>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: '',
description: '',
category: 'OTHER',
visibility: 'PRIVATE',
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const selectedFile = acceptedFiles[0];
setFile(selectedFile);
// Auto-fill title from filename if empty
if (!form.getValues('title')) {
const nameWithoutExt = selectedFile.name.replace(/\.[^/.]+$/, '');
form.setValue('title', nameWithoutExt);
}
}
}, [form]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'text/plain': ['.txt'],
},
});
const onSubmit = async (data: UploadFormData) => {
if (!file) {
toast.error('Please select a file to upload');
return;
}
try {
await uploadMutation.mutateAsync({
file,
...data,
});
toast.success('Document uploaded successfully');
router.push('/dashboard/documents');
} catch (error) {
toast.error('Failed to upload document');
}
};
const removeFile = () => {
setFile(null);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="container max-w-2xl py-8">
<Card>
<CardHeader>
<CardTitle>Upload Document</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* File Drop Zone */}
<div>
<label className="text-sm font-medium">File</label>
{!file ? (
<div
{...getRootProps()}
className={`mt-2 border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary'
}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
{isDragActive
? 'Drop the file here'
: 'Drag and drop a file here, or click to select'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
PDF, Word, Excel, Images, or Text (max 50MB)
</p>
</div>
) : (
<div className="mt-2 flex items-center justify-between p-6 rounded-2xl bg-gray-50">
<div className="flex items-center gap-3">
<File className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={removeFile}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Document title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Brief description of the document"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Category */}
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Visibility */}
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
{VISIBILITIES.map((vis) => (
<SelectItem key={vis.value} value={vis.value}>
<div>
<div>{vis.label}</div>
<div className="text-xs text-muted-foreground">
{vis.description}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Submit */}
<div className="flex gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button
type="submit"
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? 'Uploading...' : 'Upload Document'}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}Install react-dropzone:
cd apps/web
npm install react-dropzoneGate
cd apps/web
cat app/dashboard/documents/upload/page.tsx
npx tsc --noEmit
npm run build
# Should build without errorsRollback
rm -rf apps/web/app/dashboard/documents
rm apps/web/lib/queries/documents.tsLock
apps/web/app/dashboard/documents/upload/page.tsx
apps/web/lib/queries/documents.tsCheckpoint
- Document queries created
- Upload page created
- Drag-and-drop works
- Form validation works
- TypeScript compiles
Step 107: Create Document List Page
Input
- Step 106 complete
- Document queries exist
Constraints
- Show all accessible documents
- Support filtering by category
- Support search
- Pagination
Task
Create apps/web/app/dashboard/documents/page.tsx:
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
File,
FileText,
Image,
Download,
MoreHorizontal,
Plus,
Search,
Share2,
Trash2,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import {
useDocuments,
useDeleteDocument,
useDownloadDocument,
} from '@/lib/queries/documents';
import type { Document, DocumentCategory } from '@/lib/queries/documents';
const CATEGORIES = [
{ value: 'all', label: 'All Categories' },
{ value: 'POLICY', label: 'Policy' },
{ value: 'HANDBOOK', label: 'Handbook' },
{ value: 'CONTRACT', label: 'Contract' },
{ value: 'PERFORMANCE', label: 'Performance' },
{ value: 'TRAINING', label: 'Training' },
{ value: 'CERTIFICATE', label: 'Certificate' },
{ value: 'PERSONAL', label: 'Personal' },
{ value: 'OTHER', label: 'Other' },
] as const;
const VISIBILITY_LABELS: Record<string, string> = {
PRIVATE: 'Private',
TEAM: 'Team',
DEPARTMENT: 'Department',
MANAGERS: 'Managers',
COMPANY: 'Company',
CUSTOM: 'Custom',
};
const VISIBILITY_COLORS: Record<string, string> = {
PRIVATE: 'bg-gray-100 text-gray-800',
TEAM: 'bg-blue-100 text-blue-800',
DEPARTMENT: 'bg-purple-100 text-purple-800',
MANAGERS: 'bg-yellow-100 text-yellow-800',
COMPANY: 'bg-green-100 text-green-800',
CUSTOM: 'bg-orange-100 text-orange-800',
};
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) {
return <Image className="h-5 w-5 text-purple-500" />;
}
if (mimeType === 'application/pdf') {
return <FileText className="h-5 w-5 text-red-500" />;
}
return <File className="h-5 w-5 text-blue-500" />;
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default function DocumentsPage() {
const router = useRouter();
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string>('all');
const [page, setPage] = useState(1);
const [deleteId, setDeleteId] = useState<string | null>(null);
const { data, isLoading, error } = useDocuments({
category: category !== 'all' ? (category as DocumentCategory) : undefined,
search: search || undefined,
page,
limit: 20,
});
const deleteMutation = useDeleteDocument();
const downloadMutation = useDownloadDocument();
const handleDownload = async (doc: Document) => {
try {
const url = await downloadMutation.mutateAsync(doc.id);
window.open(url, '_blank');
} catch (error) {
toast.error('Failed to download document');
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteMutation.mutateAsync(deleteId);
toast.success('Document deleted');
setDeleteId(null);
} catch (error) {
toast.error('Failed to delete document');
}
};
return (
<div className="container py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Documents</h1>
<Button asChild>
<Link href="/dashboard/documents/upload">
<Plus className="h-4 w-4 mr-2" />
Upload Document
</Link>
</Button>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search documents..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="pl-9"
/>
</div>
<Select
value={category}
onValueChange={(value) => {
setCategory(value);
setPage(1);
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : error ? (
<div className="text-center py-12 text-muted-foreground">
Failed to load documents. Please try again.
</div>
) : data?.items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No documents found.
{search && ' Try adjusting your search.'}
</div>
) : (
<>
<div className="rounded-2xl overflow-hidden shadow-lg shadow-gray-200/50">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Visibility</TableHead>
<TableHead>Size</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map((doc) => (
<TableRow key={doc.id}>
<TableCell>
<div className="flex items-center gap-3">
{getFileIcon(doc.mimeType)}
<div>
<div className="font-medium">{doc.title}</div>
<div className="text-xs text-muted-foreground">
{doc.fileName}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{doc.category}</Badge>
</TableCell>
<TableCell>
<Badge
className={VISIBILITY_COLORS[doc.visibility]}
variant="secondary"
>
{VISIBILITY_LABELS[doc.visibility]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatFileSize(doc.fileSize)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(doc.createdAt as unknown as string)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/dashboard/documents/${doc.id}`)
}
>
<Eye className="h-4 w-4 mr-2" />
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDownload(doc)}>
<Download className="h-4 w-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
router.push(
`/dashboard/documents/${doc.id}/share`,
)
}
>
<Share2 className="h-4 w-4 mr-2" />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => setDeleteId(doc.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex justify-center gap-2 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-muted-foreground">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
)}
</>
)}
{/* Delete Confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this document? This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}Note: Add the
/dashboard/documentsroute to the sidebar navigation in your layout file.
Gate
cd apps/web
cat app/dashboard/documents/page.tsx
npx tsc --noEmit
npm run buildRollback
rm apps/web/app/dashboard/documents/page.tsxLock
apps/web/app/dashboard/documents/page.tsxCheckpoint
- Document list page created
- Filtering by category works
- Search works
- Pagination works
- Download action works
- Delete confirmation works
- TypeScript compiles
Step 108: Create Share Document Modal
Input
- Step 107 complete
- Document list page exists
Constraints
- Allow sharing with users, teams, or departments
- Support permission levels (VIEW, DOWNLOAD, EDIT)
- Optional expiration date
- Show existing shares
Task
First, create helper queries for teams, departments, and employees if they don't already exist from Phase 03:
// apps/web/lib/queries/org.ts
// Create this file if teams/departments/employees queries don't exist from Phase 03
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
export interface Team {
id: string;
name: string;
description: string | null;
}
export interface Department {
id: string;
name: string;
description: string | null;
}
export interface Employee {
id: string;
firstName: string;
lastName: string;
email: string;
userId: string | null;
}
export function useTeams() {
return useQuery({
queryKey: ['teams'],
queryFn: async (): Promise<Team[]> => {
// api helper adds /api/v1 prefix - Phase 03 defines @Controller('api/v1/teams')
const response = await api.get<{ items: Team[] }>('/teams');
return response.data.items;
},
});
}
export function useDepartments() {
return useQuery({
queryKey: ['departments'],
queryFn: async (): Promise<Department[]> => {
// api helper adds /api/v1 prefix - Phase 03 defines @Controller('api/v1/departments')
const response = await api.get<{ items: Department[] }>('/departments');
return response.data.items;
},
});
}
export function useEmployees(options?: { limit?: number }) {
return useQuery({
queryKey: ['employees', options],
queryFn: async (): Promise<{ items: Employee[] }> => {
const params = new URLSearchParams();
if (options?.limit) params.set('limit', options.limit.toString());
const response = await api.get<{ items: Employee[] }>(`/employees?${params}`);
return response.data;
},
});
}Create apps/web/app/dashboard/documents/[id]/share/page.tsx:
'use client';
import { useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus, Trash2, Users, Building2, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import {
useDocument,
useShareDocument,
useUnshareDocument,
AccessType,
DocumentPermission,
} from '@/lib/queries/documents';
import { useTeams, useDepartments, useEmployees } from '@/lib/queries/org';
const shareSchema = z.object({
accessType: z.enum(['USER', 'TEAM', 'DEPARTMENT']),
targetId: z.string().min(1, 'Please select a target'),
permission: z.enum(['VIEW', 'DOWNLOAD', 'EDIT']),
expiresAt: z.string().optional(),
});
type ShareFormData = z.infer<typeof shareSchema>;
const ACCESS_TYPE_ICONS = {
USER: Users,
TEAM: Users,
DEPARTMENT: Building2,
ROLE: Shield,
};
const PERMISSION_LABELS: Record<string, string> = {
VIEW: 'View only',
DOWNLOAD: 'Download',
EDIT: 'Edit',
};
const PERMISSION_COLORS: Record<string, string> = {
VIEW: 'bg-gray-100 text-gray-800',
DOWNLOAD: 'bg-blue-100 text-blue-800',
EDIT: 'bg-green-100 text-green-800',
};
export default function ShareDocumentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Next.js 15: params is a Promise, use React.use() to unwrap
const { id: documentId } = use(params);
const router = useRouter();
const { data: document, isLoading: docLoading } = useDocument(documentId);
const { data: teams } = useTeams();
const { data: departments } = useDepartments();
const { data: employees } = useEmployees({ limit: 100 });
const shareMutation = useShareDocument();
const unshareMutation = useUnshareDocument();
const [accessType, setAccessType] = useState<AccessType>('USER');
const form = useForm<ShareFormData>({
resolver: zodResolver(shareSchema),
defaultValues: {
accessType: 'USER',
targetId: '',
permission: 'VIEW',
expiresAt: '',
},
});
const onSubmit = async (data: ShareFormData) => {
try {
await shareMutation.mutateAsync({
id: documentId,
data: {
accessType: data.accessType as AccessType,
targetId: data.targetId,
permission: data.permission as DocumentPermission,
expiresAt: data.expiresAt || undefined,
},
});
toast.success('Access granted');
form.reset();
} catch (error) {
toast.error('Failed to grant access');
}
};
const handleUnshare = async (
accessType: AccessType,
targetId: string,
) => {
try {
await unshareMutation.mutateAsync({
id: documentId,
accessType,
targetId,
});
toast.success('Access revoked');
} catch (error) {
toast.error('Failed to revoke access');
}
};
const getTargetName = (accessType: string, targetId: string) => {
switch (accessType) {
case 'USER':
const employee = employees?.items.find((e) => e.userId === targetId);
return employee
? `${employee.firstName} ${employee.lastName}`
: targetId;
case 'TEAM':
const team = teams?.find((t) => t.id === targetId);
return team?.name || targetId;
case 'DEPARTMENT':
const dept = departments?.find((d) => d.id === targetId);
return dept?.name || targetId;
default:
return targetId;
}
};
const getTargetOptions = () => {
switch (accessType) {
case 'USER':
return (
employees?.items.map((e) => ({
value: e.userId || e.id,
label: `${e.firstName} ${e.lastName}`,
})) || []
);
case 'TEAM':
return (
teams?.map((t) => ({
value: t.id,
label: t.name,
})) || []
);
case 'DEPARTMENT':
return (
departments?.map((d) => ({
value: d.id,
label: d.name,
})) || []
);
default:
return [];
}
};
if (docLoading) {
return (
<div className="container max-w-2xl py-8">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!document) {
return (
<div className="container max-w-2xl py-8">
<p className="text-muted-foreground">Document not found</p>
</div>
);
}
const accessGrants = (document as any).accessGrants || [];
return (
<div className="container max-w-2xl py-8">
<Button
variant="ghost"
className="mb-4"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Card className="mb-6">
<CardHeader>
<CardTitle>Share Document</CardTitle>
<CardDescription>
{document.title}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Access Type */}
<FormField
control={form.control}
name="accessType"
render={({ field }) => (
<FormItem>
<FormLabel>Share with</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
setAccessType(value as AccessType);
form.setValue('targetId', '');
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="USER">User</SelectItem>
<SelectItem value="TEAM">Team</SelectItem>
<SelectItem value="DEPARTMENT">Department</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Target Selection */}
<FormField
control={form.control}
name="targetId"
render={({ field }) => (
<FormItem>
<FormLabel>
{accessType === 'USER' && 'Select User'}
{accessType === 'TEAM' && 'Select Team'}
{accessType === 'DEPARTMENT' && 'Select Department'}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{getTargetOptions().map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Permission Level */}
<FormField
control={form.control}
name="permission"
render={({ field }) => (
<FormItem>
<FormLabel>Permission</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="VIEW">View only</SelectItem>
<SelectItem value="DOWNLOAD">Download</SelectItem>
<SelectItem value="EDIT">Edit</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Expiration (optional) */}
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<FormItem>
<FormLabel>Expires (optional)</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={shareMutation.isPending}
>
<Plus className="h-4 w-4 mr-2" />
{shareMutation.isPending ? 'Sharing...' : 'Share'}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* Existing Shares */}
<Card>
<CardHeader>
<CardTitle>Current Access</CardTitle>
<CardDescription>
People and groups with access to this document
</CardDescription>
</CardHeader>
<CardContent>
{accessGrants.length === 0 ? (
<p className="text-sm text-muted-foreground">
No custom access grants. Document uses visibility-based access.
</p>
) : (
<div className="space-y-3">
{accessGrants.map((grant: any) => {
const Icon = ACCESS_TYPE_ICONS[grant.accessType as keyof typeof ACCESS_TYPE_ICONS] || Users;
return (
<div
key={grant.id}
className="flex items-center justify-between p-4 rounded-2xl bg-gray-50"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">
{getTargetName(grant.accessType, grant.targetId)}
</div>
<div className="text-xs text-muted-foreground">
{grant.accessType}
{grant.expiresAt && (
<> • Expires {new Date(grant.expiresAt).toLocaleDateString()}</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
className={PERMISSION_COLORS[grant.permission]}
variant="secondary"
>
{PERMISSION_LABELS[grant.permission]}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleUnshare(grant.accessType, grant.targetId)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}Gate
cd apps/web
cat app/dashboard/documents/[id]/share/page.tsx
npx tsc --noEmit
npm run buildCommon Errors
| Error | Cause | Fix |
|---|---|---|
useTeams not found | Query not defined | Create lib/queries/org.ts with useTeams |
useDepartments not found | Query not defined | Create lib/queries/org.ts with useDepartments |
useEmployees not found | Query not defined | Create lib/queries/org.ts with useEmployees |
AccessType not exported | Missing from documents.ts | Ensure types are exported from documents.ts |
Rollback
rm -rf apps/web/app/dashboard/documents/[id]
rm apps/web/lib/queries/org.tsLock
apps/web/app/dashboard/documents/[id]/share/page.tsx
apps/web/lib/queries/org.tsCheckpoint
- org.ts queries created (useTeams, useDepartments, useEmployees)
- Share page created
- User/Team/Department selection works
- Permission levels work
- Expiration date works
- Existing shares displayed
- Revoke access works
- TypeScript compiles
Phase 06 Complete
Summary
You have implemented:
- Database Models: Document and DocumentAccess with all enums
- GCS Integration: File upload to Google Cloud Storage with signed URLs
- Access Control: Multi-level visibility and fine-grained permissions
- Backend Services: Repository, AccessService, DocumentService, Controller
- Frontend Pages: Upload form, document list, share modal
Files Created
Backend (apps/api/src/documents/):
documents.module.ts- Module registration with Multer configstorage.config.tsrepositories/document.repository.tsservices/upload.service.tsservices/document-access.service.tsservices/document.service.tscontrollers/document.controller.tsdto/create-document.dto.tsdto/update-document.dto.tsdto/share-document.dto.ts
Frontend (apps/web/):
lib/api.ts- Updated with FormData supportlib/queries/documents.ts- Document queries with local typeslib/queries/org.ts- Teams, departments, employees queriesapp/dashboard/documents/page.tsxapp/dashboard/documents/upload/page.tsxapp/dashboard/documents/[id]/share/page.tsx
Sidebar Navigation: Add to your dashboard layout:
{
title: 'Documents',
href: '/dashboard/documents',
icon: FileText,
}Database:
- Migration:
add_document_management - Enums: DocumentCategory, DocumentVisibility, AccessType, DocumentPermission, DocumentStatus
- Models: Document, DocumentAccess
Locked Files After Phase 06
- All Phase 05 locks, plus:
- All document models in
schema.prisma apps/api/src/documents/*apps/web/app/dashboard/documents/*
Known Limitations (MVP)
Archive vs Delete
The current implementation uses soft delete only (status = DELETED).
Not included in MVP:
- Separate archive workflow (ARCHIVED vs DELETED distinction)
- Restore/unarchive endpoint for recovering documents
- UI for managing archived documents
Documents can be marked as DELETED via the DELETE endpoint, but cannot be recovered through the API. For MVP, manual database intervention is required to restore documents if needed.
Future Enhancement: Add PATCH /api/v1/documents/:id/status endpoint with status transitions (ACTIVE ↔ ARCHIVED, ACTIVE → DELETED) and a document recovery UI.
Next Phase
Phase 07: Tags & Custom Fields (Steps 109-122)
Phase Completion Checklist (MANDATORY)
BEFORE MOVING TO NEXT PHASE
Complete ALL items before proceeding. Do NOT skip any step.
1. Gate Verification
- All step gates passed
- Document upload working
- Document categories working
- Access control functional
- Document preview working
2. Update PROJECT_STATE.md
- Mark Phase 06 as COMPLETED with timestamp
- Update "Current Phase" to Phase 07
- Add session log entry3. Update WHAT_EXISTS.md
## Database Models
- Document, DocumentCategory
## API Endpoints
- /api/v1/documents/*
## Frontend Routes
- /dashboard/documents/*4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 06 - Document Management"
git tag phase-06-document-managementNext Phase
After verification, proceed to Phase 07: Tags & Custom Fields
Last Updated: 2025-11-30