All files / routes/pro/crm documents.routes.ts

100% Statements 55/55
100% Branches 8/8
100% Functions 4/4
100% Lines 55/55

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                                          1x     1x       5x 5x 5x 5x   5x   2x 2x 2x   3x           1x       6x 6x 6x 6x   6x   4x 4x 4x 4x   4x 1x   3x 1x   2x 1x     1x             1x   5x           1x       5x 5x 5x 5x 5x   5x   2x 1x   4x           1x       7x 7x 7x 7x 7x   7x     4x     3x 3x 1x       2x     2x   2x                 5x            
// CRM Documents Routes - File upload/download/delete
import { Hono } from "hono";
import type { Dal } from "../../../dal";
import type { Services } from "../../../services";
import { success, handleError } from "../../../lib/response";
import { parseRequiredId } from "../../../lib/utils";
import { requireProAccess } from "../../../middleware";
import { ValidationError } from "../../../lib/errors";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		proId: string;
		proRole: string;
	};
};
 
const documents = new Hono<Env>();
 
// List documents for a lead
documents.get(
	"/:proId/crm/leads/:leadId/documents",
	requireProAccess,
	async (c) => {
		try {
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
 
			await services.leads.verifyProOwnership(leadId, proId);
 
			const documentType = c.req.query("type");
			const docs = await services.documents.list(leadId, documentType);
			return success(c, docs);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
// Upload document (multipart form)
documents.post(
	"/:proId/crm/leads/:leadId/documents",
	requireProAccess,
	async (c) => {
		try {
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
 
			await services.leads.verifyProOwnership(leadId, proId);
 
			const formData = await c.req.formData();
			const file = formData.get("file") as File | null;
			const name = formData.get("name") as string | null;
			const documentType = formData.get("documentType") as string | null;
 
			if (!file) {
				throw new ValidationError("No file provided");
			}
			if (!name) {
				throw new ValidationError("Document name is required");
			}
			if (!documentType) {
				throw new ValidationError("Document type is required");
			}
 
			const doc = await services.documents.upload(
				leadId,
				proId,
				file,
				{ name, documentType },
				c.env.R2,
			);
			return success(c, doc, 201);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
// Soft delete document
documents.delete(
	"/:proId/crm/leads/:leadId/documents/:documentId",
	requireProAccess,
	async (c) => {
		try {
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
			const documentId = parseRequiredId(c.req.param("documentId"), "document");
 
			await services.leads.verifyProOwnership(leadId, proId);
 
			await services.documents.delete(documentId, leadId);
			return success(c, { deleted: true });
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
// Download document (proxy from R2)
documents.get(
	"/:proId/crm/leads/:leadId/documents/:documentId/download",
	requireProAccess,
	async (c) => {
		try {
			const services = c.get("services");
			const proId = c.get("proId");
			const leadId = parseRequiredId(c.req.param("leadId"), "lead");
			const documentId = parseRequiredId(c.req.param("documentId"), "document");
 
			await services.leads.verifyProOwnership(leadId, proId);
 
			const { fileKey, filename, mimeType } =
				await services.documents.getDownloadUrl(documentId, leadId);
 
			// Fetch from R2 and proxy to client
			const object = await c.env.R2.get(fileKey);
			if (!object) {
				throw new ValidationError("File not found in storage");
			}
 
			// Sanitize filename to prevent header injection
			const safeFilename = filename
				.replace(/[\r\n]/g, "") // Strip CR/LF
				.replace(/["\\/]/g, "_"); // Replace quotes/slashes
			const encodedFilename = encodeURIComponent(safeFilename);
 
			return new Response(object.body, {
				headers: {
					"Content-Type": mimeType,
					"Content-Disposition": `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`,
					"Content-Security-Policy": "default-src 'none'",
					"X-Content-Type-Options": "nosniff",
				},
			});
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
export default documents;