All files / routes/marketplace/pros taxonomy-enrichment.ts

100% Statements 78/78
97.91% Branches 47/48
100% Functions 9/9
100% Lines 73/73

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 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249                                                                                                                                      102x   58x                         58x 23x 4x   58x 58x 4x 4x   4x   4x 4x 3x 3x 3x     4x       4x 3x 3x 3x     4x 6x 4x 6x 6x       58x 58x 23x 23x 22x 23x 19x 19x       58x                       20x 20x                     28x     1x             1x                                     36x 8x     28x   28x 28x 4x     28x 28x 4x     28x 28x 4x     28x 28x 3x     28x 28x 3x     28x 28x   28x 4x 4x     28x 4x 4x     28x 4x 4x       4x 4x 4x                 28x     28x    
// Taxonomy enrichment + portfolio-cover helpers for marketplace pro routes.
//
// Extracted from `marketplace/pros.routes.ts` because the cache-aware
// taxonomy fetch + per-pro enrichment loop run for both the list endpoint
// and the by-id endpoint, and they own their own caching policy (KV-backed,
// dual L1+L2 layer). Keeping them outside the route file makes the route
// handlers easier to read and the enrichment policy easier to evolve.
 
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Dal } from "../../../dal";
import type { getDb } from "../../../db";
import {
	type BusinessType,
	businessTypes,
	type City,
	type CustomerSegment,
	cities,
	customerSegments,
	type Locality,
	localities,
	type Media,
	type Pro,
	projects,
	type Room,
	type Zone,
	zones,
} from "../../../db/schema";
import { CACHE_KEYS, CACHE_TTL, type DualCache } from "../../../lib/cache";
import { resolveProjectCover } from "../../../services/cover-cascade";
 
// Pro shape with denormalized taxonomy names attached.
export type ProWithTaxonomy = Pro & {
	businessType?: string | null;
	customerSegment?: string | null;
	primaryLocality?: {
		id: string;
		name: string;
		zone: string;
		city: string;
	} | null;
};
 
// All five taxonomy tables fetched together so a single cache hit covers
// every lookup the enrichment step needs.
export type TaxonomyTables = {
	businessTypes: BusinessType[];
	customerSegments: CustomerSegment[];
	localities: Locality[];
	zones: Zone[];
	cities: City[];
};
 
/**
 * Resolve up to 4 most-recent published-project covers per pro, with a fallback
 * cascade through rooms→media for projects whose denormalized `projects.coverImage`
 * is NULL. Returns `Map<proId, string[]>` (ordered by project dateCreated DESC,
 * capped at 4). Pros with no resolvable covers are simply absent from the map.
 *
 * The cascade fallback matches `enrichProjectsWithTaxonomy` (used by the detail
 * page's `projects` array), so the listing card and the detail-page Portfolio
 * grid surface the same set of images.
 */
export async function resolvePortfolioCoversForPros(
	db: ReturnType<typeof getDb>,
	dal: Dal,
	proIds: string[],
): Promise<Map<string, string[]>> {
	if (proIds.length === 0) return new Map();
 
	const projectRows = await db
		.select({
			id: projects.id,
			proId: projects.proId,
			coverImage: projects.coverImage,
			defaultRoomId: projects.defaultRoomId,
		})
		.from(projects)
		.where(
			and(inArray(projects.proId, proIds), eq(projects.status, "published")),
		)
		.orderBy(desc(projects.dateCreated));
 
	const cascadeProjectIds = projectRows
		.filter((p) => !p.coverImage)
		.map((p) => p.id);
 
	const cascadeCoverByProjectId = new Map<string, string>();
	if (cascadeProjectIds.length > 0) {
		const rooms = await dal.rooms.findByProjectIds(cascadeProjectIds);
		const roomIds = rooms.map((r) => r.id);
		const allMedia =
			roomIds.length > 0 ? await dal.media.findByRoomIds(roomIds) : [];
 
		const mediaByRoomId = new Map<number, Media[]>();
		for (const m of allMedia) {
			const existing = mediaByRoomId.get(m.roomId) ?? [];
			existing.push(m);
			mediaByRoomId.set(m.roomId, existing);
		}
 
		const roomsByProjectId = new Map<
			string,
			Array<Room & { media: Media[] }>
		>();
		for (const room of rooms) {
			const existing = roomsByProjectId.get(room.projectId) ?? [];
			existing.push({ ...room, media: mediaByRoomId.get(room.id) ?? [] });
			roomsByProjectId.set(room.projectId, existing);
		}
 
		for (const project of projectRows) {
			if (project.coverImage) continue;
			const projectRooms = roomsByProjectId.get(project.id) ?? [];
			const cascaded = resolveProjectCover(projectRooms, project.defaultRoomId);
			if (cascaded) cascadeCoverByProjectId.set(project.id, cascaded);
		}
	}
 
	const coversByProId = new Map<string, string[]>();
	for (const row of projectRows) {
		const cover = row.coverImage || cascadeCoverByProjectId.get(row.id);
		if (!cover) continue;
		const existing = coversByProId.get(row.proId) ?? [];
		if (existing.length < 4) {
			existing.push(cover);
			coversByProId.set(row.proId, existing);
		}
	}
 
	return coversByProId;
}
 
/**
 * Fetch up to 4 recent portfolio covers for a single pro, with rooms→media
 * cascade fallback. Thin wrapper around `resolvePortfolioCoversForPros`.
 */
export async function fetchPortfolioCovers(
	db: ReturnType<typeof getDb>,
	dal: Dal,
	proId: string,
): Promise<string[]> {
	const map = await resolvePortfolioCoversForPros(db, dal, [proId]);
	return map.get(proId) ?? [];
}
 
/**
 * Fetch all taxonomy tables, cached with 30-min KV TTL.
 * On cache hit: 0 D1 queries. On miss: 5 parallel queries.
 */
export async function getCachedTaxonomyTables(
	db: ReturnType<typeof getDb>,
	cache: DualCache,
): Promise<TaxonomyTables> {
	return cache.getOrSet(
		CACHE_KEYS.TAXONOMY_ENRICHMENT,
		async () => {
			const [bt, cs, loc, z, ct] = await Promise.all([
				db.select().from(businessTypes),
				db.select().from(customerSegments),
				db.select().from(localities),
				db.select().from(zones),
				db.select().from(cities),
			]);
			return {
				businessTypes: bt,
				customerSegments: cs,
				localities: loc,
				zones: z,
				cities: ct,
			};
		},
		{ l1Ttl: CACHE_TTL.TAXONOMY_L1, l2Ttl: CACHE_TTL.TAXONOMY_L2 },
	);
}
 
// Build per-table lookup maps + attach businessType / customerSegment names
// and the primary locality (zone + city) on each pro.
export async function enrichProsWithTaxonomy(
	db: ReturnType<typeof getDb>,
	proList: Pro[],
	cache: DualCache,
): Promise<ProWithTaxonomy[]> {
	if (proList.length === 0) {
		return [];
	}
 
	const taxonomy = await getCachedTaxonomyTables(db, cache);
 
	const businessTypesMap = new Map<string, BusinessType>();
	for (const bt of taxonomy.businessTypes) {
		businessTypesMap.set(bt.id, bt);
	}
 
	const customerSegmentsMap = new Map<string, CustomerSegment>();
	for (const cs of taxonomy.customerSegments) {
		customerSegmentsMap.set(cs.id, cs);
	}
 
	const localitiesMap = new Map<string, Locality>();
	for (const loc of taxonomy.localities) {
		localitiesMap.set(loc.id, loc);
	}
 
	const zonesMap = new Map<string, Zone>();
	for (const zone of taxonomy.zones) {
		zonesMap.set(zone.id, zone);
	}
 
	const citiesMap = new Map<string, City>();
	for (const city of taxonomy.cities) {
		citiesMap.set(city.id, city);
	}
 
	const enrichedPros: ProWithTaxonomy[] = proList.map((pro) => {
		const enriched: ProWithTaxonomy = { ...pro };
 
		if (pro.businessTypeId) {
			const bt = businessTypesMap.get(pro.businessTypeId);
			enriched.businessType = bt?.name || null;
		}
 
		if (pro.customerSegmentId) {
			const cs = customerSegmentsMap.get(pro.customerSegmentId);
			enriched.customerSegment = cs?.name || null;
		}
 
		if (pro.serviceAreaIds && pro.serviceAreaIds.length > 0) {
			const localityId = pro.serviceAreaIds[0];
			const locality = localitiesMap.get(localityId);
			/* v8 ignore start -- defensive guard: locality always found in seeded data */
			if (locality) {
				/* v8 ignore stop */
				const zone = zonesMap.get(locality.zoneId);
				const city = zone ? citiesMap.get(zone.cityId) : undefined;
				enriched.primaryLocality = {
					id: locality.id,
					name: locality.name,
					zone: zone?.name || "",
					city: city?.name || "",
				};
			}
		}
 
		return enriched;
	});
 
	return enrichedPros;
}