All files / services/crm documents.service.ts

100% Statements 47/47
100% Branches 36/36
100% Functions 5/5
100% Lines 47/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191              5x   5x 5x   5x                       5x                         29x     3x 1x       2x                     21x 21x 1x       20x 20x 1x           19x 21x 3x           16x 1x       15x 1x           14x 14x 2x           12x               21x 12x               12x   3x 3x       12x                       12x                     12x       4x 4x 2x   2x 1x     1x     1x                             4x 4x 2x   2x 1x     1x              
// Documents Service - File upload to R2 with categorization
import type { Dal } from "../../dal";
import type { LeadDocument } from "../../db/schema";
import { DOCUMENT_TYPES } from "../../db/schema/enums";
import { NotFoundError, ValidationError } from "../../lib/errors";
import { generateId } from "../../lib/utils";
 
const VALID_DOCUMENT_TYPES = new Set<string>(DOCUMENT_TYPES);
 
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_DOCUMENTS_PER_LEAD = 50;
 
const ALLOWED_EXTENSIONS = [
	"pdf",
	"jpg",
	"jpeg",
	"png",
	"webp",
	"docx",
	"xlsx",
	"xls",
	"dwg",
];
 
const EXTENSION_MIME_MAP: Record<string, string> = {
	pdf: "application/pdf",
	jpg: "image/jpeg",
	jpeg: "image/jpeg",
	png: "image/png",
	webp: "image/webp",
	docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
	xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
	xls: "application/vnd.ms-excel",
	dwg: "application/acad",
};
 
export class DocumentsService {
	constructor(private dal: Dal) {}
 
	async list(leadId: number, documentType?: string): Promise<LeadDocument[]> {
		if (documentType && !VALID_DOCUMENT_TYPES.has(documentType)) {
			throw new ValidationError(
				`Invalid document type. Allowed: ${DOCUMENT_TYPES.join(", ")}`,
			);
		}
		return this.dal.leadDocuments.findByLeadId(leadId, documentType);
	}
 
	async upload(
		leadId: number,
		proId: string,
		file: File,
		input: { name: string; documentType: string },
		r2: R2Bucket,
	): Promise<LeadDocument> {
		// Validate lead exists
		const lead = await this.dal.leads.findById(leadId);
		if (!lead) {
			throw new NotFoundError("Lead", String(leadId));
		}
 
		// Validate document count limit
		const docCount = await this.dal.leadDocuments.countByLeadId(leadId);
		if (docCount >= MAX_DOCUMENTS_PER_LEAD) {
			throw new ValidationError(
				`Maximum ${MAX_DOCUMENTS_PER_LEAD} documents per lead`,
			);
		}
 
		// Validate file type
		const ext = file.name.split(".").pop()?.toLowerCase() || "";
		if (!ALLOWED_EXTENSIONS.includes(ext)) {
			throw new ValidationError(
				`Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(", ")}`,
			);
		}
 
		// Validate file size
		if (file.size > MAX_FILE_SIZE) {
			throw new ValidationError("File too large. Maximum size is 10MB");
		}
 
		// Validate document type
		if (!VALID_DOCUMENT_TYPES.has(input.documentType)) {
			throw new ValidationError(
				`Invalid document type. Allowed: ${DOCUMENT_TYPES.join(", ")}`,
			);
		}
 
		// Validate name
		const name = input.name.trim();
		if (!name || name.length > 100) {
			throw new ValidationError(
				"Document name is required (max 100 characters)",
			);
		}
 
		// Generate R2 key
		const fileKey = `crm/${proId}/leads/${leadId}/${generateId()}.${ext}`;
 
		// Derive MIME type from validated extension (not browser-supplied file.type)
		/* v8 ignore start -- fallback unreachable: ext already validated against ALLOWED_EXTENSIONS */
		const mimeType = EXTENSION_MIME_MAP[ext] || "application/octet-stream";
		/* v8 ignore stop */
 
		// Upload to R2
		const arrayBuffer = await file.arrayBuffer();
		await r2.put(fileKey, arrayBuffer, {
			httpMetadata: {
				contentType: mimeType,
			},
		});
 
		// Auto-assign quote revision number
		let quoteRevisionNumber: number | undefined;
		if (input.documentType === "quote") {
			const maxRevision =
				await this.dal.leadDocuments.getMaxQuoteRevision(leadId);
			quoteRevisionNumber = maxRevision + 1;
		}
 
		// Create document record
		const doc = await this.dal.leadDocuments.create({
			leadId,
			name,
			documentType: input.documentType as LeadDocument["documentType"],
			fileKey,
			originalFilename: file.name,
			fileSizeBytes: file.size,
			mimeType,
			quoteRevisionNumber: quoteRevisionNumber ?? null,
		});
 
		// Log activity
		await this.dal.leadActivities.create({
			leadId,
			activityType: "document_attached",
			content: `Document "${name}" attached (${input.documentType})`,
			metadataJson: {
				documentId: doc.id,
				documentType: input.documentType,
				filename: file.name,
			},
		});
 
		return doc;
	}
 
	async delete(documentId: number, leadId: number): Promise<void> {
		const doc = await this.dal.leadDocuments.findById(documentId);
		if (!doc || doc.deletedAt) {
			throw new NotFoundError("Document", String(documentId));
		}
		if (doc.leadId !== leadId) {
			throw new ValidationError("Document does not belong to this lead");
		}
 
		await this.dal.leadDocuments.softDelete(documentId);
 
		// Log activity
		await this.dal.leadActivities.create({
			leadId,
			activityType: "document_deleted",
			content: `Document "${doc.name}" removed`,
			metadataJson: {
				documentId: doc.id,
				documentType: doc.documentType,
			},
		});
	}
 
	async getDownloadUrl(
		documentId: number,
		leadId: number,
	): Promise<{ fileKey: string; filename: string; mimeType: string }> {
		const doc = await this.dal.leadDocuments.findById(documentId);
		if (!doc || doc.deletedAt) {
			throw new NotFoundError("Document", String(documentId));
		}
		if (doc.leadId !== leadId) {
			throw new ValidationError("Document does not belong to this lead");
		}
 
		return {
			fileKey: doc.fileKey,
			filename: doc.originalFilename,
			mimeType: doc.mimeType,
		};
	}
}