All files / services room.service.ts

100% Statements 89/89
100% Branches 51/51
100% Functions 17/17
100% Lines 83/83

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 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266                                                          46x     1x       23x 23x 4x   19x       3x 3x 1x         3x 2x 1x   1x               10x   8x 8x 1x     7x   7x       10x 10x 1x     7x                         7x     7x 6x     7x                     2x         2x 2x       7x     6x 3x 3x 1x   2x 2x 1x 1x       1x         5x       5x 1x     4x               8x 7x   7x   3x 3x 1x           2x 3x 2x 1x       6x             3x 3x   3x 2x     1x               2x 6x   2x 6x 1x           1x                 3x 6x   3x     2x     2x       2x 6x       6x 4x   2x   6x     6x               10x 1x       9x 9x 1x        
// Room Service - Business Logic Layer
import type { Dal } from "../dal";
import type { Room, NewRoom } from "../db/schema";
import { NotFoundError, ValidationError, ForbiddenError } from "../lib/errors";
import {
	generateRoomSlug,
	generateRoomSlugWithSuffix,
} from "../lib/room-slug";
 
export type CreateRoomInput = {
	projectId: string;
	roomType: string;
	name?: string;
	areaSqft?: number;
	budgetSpent?: "under_50k" | "50k_1l" | "1l_2l" | "2l_5l" | "above_5l";
	materials?: string[];
	styleTags?: string[];
};
 
export type UpdateRoomInput = {
	roomType?: string;
	name?: string;
	areaSqft?: number;
	budgetSpent?: "under_50k" | "50k_1l" | "1l_2l" | "2l_5l" | "above_5l" | null;
	materials?: string[] | null;
	styleTags?: string[] | null;
};
 
export class RoomService {
	constructor(private dal: Dal) {}
 
	async getByProjectId(projectId: string): Promise<Room[]> {
		return this.dal.rooms.findByProjectId(projectId);
	}
 
	async getById(id: number): Promise<Room> {
		const room = await this.dal.rooms.findById(id);
		if (!room) {
			throw new NotFoundError("Room", String(id));
		}
		return room;
	}
 
	async getDefaultRoom(projectId: string): Promise<Room | undefined> {
		const project = await this.dal.projects.findById(projectId);
		if (!project?.defaultRoomId) return undefined;
		return this.dal.rooms.findById(project.defaultRoomId);
	}
 
	async setDefaultRoom(projectId: string, roomId: number): Promise<void> {
		// Verify room belongs to this project
		const room = await this.getById(roomId);
		if (room.projectId !== projectId) {
			throw new ValidationError("Room does not belong to this project");
		}
		await this.dal.projects.update(projectId, { defaultRoomId: roomId });
	}
 
	/**
	 * Creates a new room in a project
	 * If the project has no default room yet, the new room becomes the default
	 */
	async create(input: CreateRoomInput): Promise<Room> {
		await this.validateInput(input);
 
		const project = await this.dal.projects.findById(input.projectId);
		if (!project) {
			throw new NotFoundError("Project", input.projectId);
		}
 
		const maxSort = await this.dal.rooms.getMaxSortOrder(input.projectId);
 
		const roomType = await this.dal.roomTypes.findByCode(input.roomType);
		/* v8 ignore start -- V8 artifact: ?? fallback */
		const roomTypeDisplayName = roomType?.displayName ?? input.roomType;
		/* v8 ignore stop */
		let slug = generateRoomSlug(roomTypeDisplayName, project.title);
		if (await this.dal.rooms.slugExists(slug)) {
			slug = generateRoomSlugWithSuffix(roomTypeDisplayName, project.title);
		}
 
		const roomData: NewRoom = {
			projectId: input.projectId,
			roomType: input.roomType,
			name: input.name,
			isDefault: false,
			sortOrder: maxSort + 1,
			areaSqft: input.areaSqft,
			budgetSpent: input.budgetSpent,
			materials: input.materials,
			styleTags: input.styleTags,
			slug,
		};
 
		const room = await this.dal.rooms.create(roomData);
 
		// If project has no default room yet, set this one
		if (!project.defaultRoomId) {
			await this.dal.projects.update(input.projectId, { defaultRoomId: room.id });
		}
 
		return room;
	}
 
	/**
	 * Creates a default room for a new project
	 * Called automatically when a project is created
	 */
	async createDefaultRoom(
		projectId: string,
		roomType: string = "full_home",
	): Promise<Room> {
		const room = await this.create({
			projectId,
			roomType,
		});
		// Explicitly set as default
		await this.dal.projects.update(projectId, { defaultRoomId: room.id });
		return room;
	}
 
	async update(id: number, input: UpdateRoomInput): Promise<Room> {
		const room = await this.getById(id);
 
		let slug: string | undefined;
		if (input.roomType) {
			const roomType = await this.dal.roomTypes.findByCode(input.roomType);
			if (!roomType) {
				throw new ValidationError(`Invalid room type: ${input.roomType}`);
			}
			const project = await this.dal.projects.findById(room.projectId);
			if (project) {
				const roomTypeDisplayName = roomType.displayName;
				slug = generateRoomSlug(roomTypeDisplayName, project.title);
				/* v8 ignore start -- defensive guard: slug collision unlikely */
				if (await this.dal.rooms.slugExists(slug, id)) {
				/* v8 ignore stop */
					slug = generateRoomSlugWithSuffix(roomTypeDisplayName, project.title);
				}
			}
		}
 
		const updated = await this.dal.rooms.update(id, {
			...input,
			...(slug !== undefined ? { slug } : {}),
		});
		if (!updated) {
			throw new NotFoundError("Room", String(id));
		}
 
		return updated;
	}
 
	/**
	 * Deletes a room and all its media
	 * Cannot delete the default room if it's the only room
	 */
	async delete(id: number): Promise<void> {
		const room = await this.getById(id);
		const project = await this.dal.projects.findById(room.projectId);
 
		if (project && project.defaultRoomId === id) {
			// Check if there are other rooms
			const roomCount = await this.dal.rooms.countByProjectId(room.projectId);
			if (roomCount <= 1) {
				throw new ValidationError(
					"Cannot delete the only room in a project. Add another room first or delete the project.",
				);
			}
 
			// Reassign default to another room
			const rooms = await this.dal.rooms.findByProjectId(room.projectId);
			const nextRoom = rooms.find((r) => r.id !== id);
			if (nextRoom) {
				await this.dal.projects.update(room.projectId, { defaultRoomId: nextRoom.id });
			}
		}
 
		await this.dal.rooms.delete(id);
	}
 
	/**
	 * Verifies that a room belongs to a pro's project
	 */
	async verifyProOwnership(roomId: number, proId: string): Promise<Room> {
		const room = await this.getById(roomId);
		const project = await this.dal.projects.findById(room.projectId);
 
		if (!project || project.proId !== proId) {
			throw new ForbiddenError("Room does not belong to your pro");
		}
 
		return room;
	}
 
	/**
	 * Reorder rooms in a project
	 */
	async reorder(projectId: string, roomIds: number[]): Promise<void> {
		// Verify all rooms belong to the project
		const existingRooms = await this.dal.rooms.findByProjectId(projectId);
		const existingIds = new Set(existingRooms.map((r) => r.id));
 
		for (const id of roomIds) {
			if (!existingIds.has(id)) {
				throw new ValidationError(
					`Room ${id} does not belong to project ${projectId}`,
				);
			}
		}
 
		await this.dal.rooms.updateSortOrder(roomIds);
	}
 
	/**
	 * Get rooms with media counts
	 */
	async getRoomsWithMediaCounts(
		projectId: string,
	): Promise<Array<Room & { imageCount: number; videoCount: number }>> {
		const rooms = await this.dal.rooms.findByProjectId(projectId);
		const roomIds = rooms.map((r) => r.id);
 
		if (roomIds.length === 0) return [];
 
		// Get all media for all rooms
		const allMedia = await this.dal.media.findByRoomIds(roomIds);
 
		// Count media per room
		const mediaCountMap = new Map<
			number,
			{ imageCount: number; videoCount: number }
		>();
		for (const media of allMedia) {
			const counts = mediaCountMap.get(media.roomId) || {
				imageCount: 0,
				videoCount: 0,
			};
			if (media.mediaType === "image") {
				counts.imageCount++;
			} else {
				counts.videoCount++;
			}
			mediaCountMap.set(media.roomId, counts);
		}
 
		return rooms.map((room) => ({
			...room,
			imageCount: mediaCountMap.get(room.id)?.imageCount ?? 0,
			videoCount: mediaCountMap.get(room.id)?.videoCount ?? 0,
		}));
	}
 
	private async validateInput(input: CreateRoomInput): Promise<void> {
		if (!input.roomType?.trim()) {
			throw new ValidationError("Room type is required");
		}
 
		// Verify room type exists
		const roomTypeExists = await this.dal.roomTypes.exists(input.roomType);
		if (!roomTypeExists) {
			throw new ValidationError(`Invalid room type: ${input.roomType}`);
		}
	}
}