All files / dal ho-favorites.dal.ts

100% Statements 39/39
100% Branches 22/22
100% Functions 13/13
100% Lines 35/35

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                      40x     3x 3x 2x   3x                 2x                     2x       2x 1x                     2x       1x                             3x 1x     5x           2x           2x             1x                               7x 7x   7x 1x     7x   1x     7x               7x 7x   7x       7x       3x 3x 1x   3x         3x                                                 3x                                           3x              
import { eq, and, inArray, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import type * as schema from "../db/schema";
import { hoFavorites, rooms, roomTypes } from "../db/schema";
 
export type FavoriteInput = {
	entityType: "pro" | "project" | "room" | "photo";
	entityId: string;
};
 
export class HoFavoritesDal {
	constructor(private db: DrizzleD1Database<typeof schema>) {}
 
	async findByUser(userId: string, entityType?: string) {
		const conditions = [eq(hoFavorites.userId, userId)];
		if (entityType) {
			conditions.push(eq(hoFavorites.entityType, entityType as "pro" | "project" | "room" | "photo"));
		}
		return this.db
			.select()
			.from(hoFavorites)
			.where(and(...conditions))
			.orderBy(hoFavorites.createdAt)
			.all();
	}
 
	async check(userId: string, entityType: string, entityId: string) {
		const row = await this.db
			.select({ id: hoFavorites.id })
			.from(hoFavorites)
			.where(
				and(
					eq(hoFavorites.userId, userId),
					eq(hoFavorites.entityType, entityType as "pro" | "project" | "room" | "photo"),
					eq(hoFavorites.entityId, entityId),
				),
			)
			.get();
		return !!row;
	}
 
	async checkBatch(userId: string, entityType: string, entityIds: string[]) {
		if (entityIds.length === 0) return new Set<string>();
		const rows = await this.db
			.select({ entityId: hoFavorites.entityId })
			.from(hoFavorites)
			.where(
				and(
					eq(hoFavorites.userId, userId),
					eq(hoFavorites.entityType, entityType as "pro" | "project" | "room" | "photo"),
					inArray(hoFavorites.entityId, entityIds),
				),
			)
			.all();
		return new Set(rows.map((r) => r.entityId));
	}
 
	async add(userId: string, entityType: string, entityId: string) {
		return this.db
			.insert(hoFavorites)
			.values({
				userId,
				entityType: entityType as "pro" | "project" | "room" | "photo",
				entityId,
			})
			.onConflictDoNothing()
			.run();
	}
 
	async bulkCreate(
		userId: string,
		items: FavoriteInput[],
	): Promise<{ inserted: number; skipped: number }> {
		if (items.length === 0) {
			return { inserted: 0, skipped: 0 };
		}
 
		const rows = items.map((item) => ({
			userId,
			entityType: item.entityType,
			entityId: item.entityId,
		}));
 
		const inserted = await this.db
			.insert(hoFavorites)
			.values(rows)
			.onConflictDoNothing()
			.returning();
 
		return {
			inserted: inserted.length,
			skipped: items.length - inserted.length,
		};
	}
 
	async remove(userId: string, entityType: string, entityId: string) {
		return this.db
			.delete(hoFavorites)
			.where(
				and(
					eq(hoFavorites.userId, userId),
					eq(hoFavorites.entityType, entityType as "pro" | "project" | "room" | "photo"),
					eq(hoFavorites.entityId, entityId),
				),
			)
			.run();
	}
 
	async findByUserPaginated(
		userId: string,
		opts: { limit?: number; cursor?: string; entityType?: string },
	) {
		const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100);
		const conditions = [eq(hoFavorites.userId, userId)];
 
		if (opts.entityType) {
			conditions.push(eq(hoFavorites.entityType, opts.entityType as "pro" | "project" | "room" | "photo"));
		}
 
		if (opts.cursor) {
			// cursor is the createdAt timestamp (unix seconds) of the last item
			conditions.push(sql`${hoFavorites.createdAt} < ${parseInt(opts.cursor, 10)}`);
		}
 
		const rows = await this.db
			.select()
			.from(hoFavorites)
			.where(and(...conditions))
			.orderBy(sql`${hoFavorites.createdAt} DESC`)
			.limit(limit + 1) // fetch one extra to detect "has more"
			.all();
 
		const hasMore = rows.length > limit;
		const data = hasMore ? rows.slice(0, limit) : rows;
		const nextCursor =
			hasMore && data.length > 0
				? String(Math.floor(data[data.length - 1].createdAt.getTime() / 1000))
				: null;
 
		return { data, nextCursor };
	}
 
	async count(userId: string, entityType?: string) {
		const conditions = [eq(hoFavorites.userId, userId)];
		if (entityType) {
			conditions.push(eq(hoFavorites.entityType, entityType as "pro" | "project" | "room" | "photo"));
		}
		const result = await this.db
			.select({ count: sql<number>`count(*)` })
			.from(hoFavorites)
			.where(and(...conditions))
			.get();
		return result?.count ?? 0;
	}
 
	/**
	 * Group the user's room favorites by roomType (e.g. "kitchen",
	 * "master_bedroom") and return the count + display name for each.
	 *
	 * Powers the /account hub's activity bar rule 2: "you saved 5 kitchen
	 * photos — see all". Joins through `rooms` (entity-id of a room
	 * favorite is the integer rooms.id) and `room_types` to get the
	 * displayable name in one round-trip. Skips non-room favorites.
	 *
	 * Returns rows sorted by count DESC so the caller can read the top
	 * roomType as `result[0]` without an extra sort.
	 */
	async countByRoomType(userId: string): Promise<
		Array<{ roomType: string; roomTypeName: string; count: number }>
	> {
		// hoFavorites.entityId is text (the favorites schema accepts any
		// 1-128 char string), but for entityType='room' it should be the
		// stringified integer rooms.id. Defensive GLOB filter strips any
		// non-numeric entityId BEFORE the CAST so a corrupted/malformed
		// row (e.g., 'abc' written by a buggy client) silently drops out
		// of the count instead of CAST-ing to 0 and joining to whatever
		// happens to be at rooms.id=0.
		const rows = await this.db
			.select({
				roomType: rooms.roomType,
				roomTypeName: roomTypes.displayName,
				count: sql<number>`COUNT(*)`,
			})
			.from(hoFavorites)
			.innerJoin(
				rooms,
				sql`${rooms.id} = CAST(${hoFavorites.entityId} AS INTEGER) AND ${hoFavorites.entityId} GLOB '[0-9]*'`,
			)
			.innerJoin(roomTypes, eq(roomTypes.code, rooms.roomType))
			.where(
				and(
					eq(hoFavorites.userId, userId),
					eq(hoFavorites.entityType, "room"),
				),
			)
			.groupBy(rooms.roomType, roomTypes.displayName)
			.orderBy(sql`COUNT(*) DESC`)
			.all();
 
		return rows.map((r) => ({
			roomType: r.roomType,
			roomTypeName: r.roomTypeName,
			count: Number(r.count),
		}));
	}
}