All files / routes/homeowner/profile avatar.routes.ts

100% Statements 49/49
100% Branches 8/8
100% Functions 5/5
100% Lines 48/48

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                              2x           13x     2x                               7x 3x 3x   1x 1x               7x 7x     7x 7x         1x 1x         2x 9x 9x 9x 1x     8x 8x 8x 1x   7x   7x 7x 7x         9x 9x       5x 5x             5x 5x   5x   2x         2x 4x 4x 4x 1x     3x 3x 3x   3x             2x 2x   2x   1x          
// Profile-picture upload for homeowner accounts. Mirrors the pro blog
// cover-image pattern (apps/api/src/routes/pro/blogs/images.routes.ts) —
// direct multipart upload to R2, no presigning — since this is a single
// small image, not the bulk project-media flow.
 
import { Hono } from "hono";
import { createDal } from "../../../dal";
import { getDb } from "../../../db";
import { validateUploadedFile } from "../../../lib/file-validation";
import { error, handleError, success } from "../../../lib/response";
import { generateId } from "../../../lib/utils";
 
type HoUser = { id: string; name: string; email: string };
type Variables = { hoUser: HoUser | null };
 
const avatar = new Hono<{
	Bindings: CloudflareBindings;
	Variables: Variables;
}>();
 
function getUser(c: { get(key: "hoUser"): HoUser | null }): HoUser | null {
	return c.get("hoUser");
}
 
const MIME_TO_EXT: Record<string, string> = {
	"image/jpeg": "jpg",
	"image/png": "png",
	"image/webp": "webp",
	"image/gif": "gif",
	"image/avif": "avif",
};
 
// Drops the previously-uploaded avatar object so R2 doesn't accumulate an
// orphan per re-upload. Best-effort: a stale blob is harmless (nothing
// references it once ho_users.image is overwritten), so a delete failure
// must not fail the request that already succeeded.
async function deleteOldAvatar(
	r2: R2Bucket,
	previousImageUrl: string | null | undefined,
): Promise<void> {
	if (!previousImageUrl?.startsWith("/api/images/homeowner/")) return;
	try {
		await r2.delete(previousImageUrl.replace("/api/images/", ""));
	} catch (err) {
		const { logger } = await import("../../../lib/logger");
		logger.error("[HO-AVATAR] failed to delete previous avatar:", err);
	}
}
 
async function refreshSession(c: {
	env: CloudflareBindings;
	req: { raw: { headers: Headers } };
}): Promise<void> {
	try {
		const { refreshHoSessionCache } = await import(
			"../../../lib/ho-session-cache"
		);
		const { createDualCache } = await import("../../../lib/cache");
		await refreshHoSessionCache(
			createDualCache(c.env.KV_CACHE),
			c.req.raw.headers,
		);
	} catch (err) {
		const { logger } = await import("../../../lib/logger");
		logger.error("[HO-AVATAR] session refresh failed:", err);
	}
}
 
// POST /me/avatar - Upload/replace the profile picture
avatar.post("/me/avatar", async (c) => {
	try {
		const user = getUser(c);
		if (!user) {
			return error(c, "UNAUTHORIZED", "Authentication required", 401);
		}
 
		const formData = await c.req.formData();
		const file = formData.get("file");
		if (!(file instanceof File)) {
			return error(c, "VALIDATION_ERROR", "No file provided", 400);
		}
		validateUploadedFile(file);
 
		const db = getDb(c.env.DB);
		const dal = createDal(db);
		const existing = await dal.hoUsers.findById(user.id);
 
		/* v8 ignore start -- V8 artifact: || fallback never reached, validateUploadedFile's allowlist is a subset of MIME_TO_EXT's keys */
		const ext = MIME_TO_EXT[file.type] || "jpg";
		/* v8 ignore stop */
		const key = `homeowner/${user.id}/avatar-${generateId()}.${ext}`;
		await c.env.R2.put(key, await file.arrayBuffer(), {
			httpMetadata: { contentType: file.type },
		});
 
		const imageUrl = `/api/images/${key}`;
		const updated = await dal.hoUsers.update(user.id, { image: imageUrl });
		/* v8 ignore start -- defensive guard: user just authenticated */
		if (!updated) {
			return error(c, "NOT_FOUND", "User not found", 404);
		}
		/* v8 ignore stop */
 
		await deleteOldAvatar(c.env.R2, existing?.image);
		await refreshSession(c);
 
		return success(c, { image: updated.image });
	} catch (err) {
		return handleError(c, err);
	}
});
 
// DELETE /me/avatar - Remove the profile picture (falls back to initials)
avatar.delete("/me/avatar", async (c) => {
	try {
		const user = getUser(c);
		if (!user) {
			return error(c, "UNAUTHORIZED", "Authentication required", 401);
		}
 
		const db = getDb(c.env.DB);
		const dal = createDal(db);
		const existing = await dal.hoUsers.findById(user.id);
 
		const updated = await dal.hoUsers.update(user.id, { image: null });
		/* v8 ignore start -- defensive guard: user just authenticated */
		if (!updated) {
			return error(c, "NOT_FOUND", "User not found", 404);
		}
		/* v8 ignore stop */
 
		await deleteOldAvatar(c.env.R2, existing?.image);
		await refreshSession(c);
 
		return success(c, { image: null });
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default avatar;