Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1504%20passing-brightgreen" alt="1504 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1509%20passing-brightgreen" alt="1509 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1504 tests across 214 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1509 tests across 214 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |

Expand Down Expand Up @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1504 tests across 214 test files)
# All tests (1509 tests across 214 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (1504 tests, 214 files)
pnpm test # Run all tests (1509 tests, 214 files)
pnpm lint # ESLint
```

Expand Down
16 changes: 15 additions & 1 deletion apps/api/src/modules/room/dto/update-room.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import { CreateRoomDto } from './create-room.dto';

export class UpdateRoomDto extends PartialType(
OmitType(CreateRoomDto, ['propertyId', 'roomTypeId'] as const),
) {}
) {
/**
* Moving a room to another room type is a legitimate admin operation
* (re-categorising inventory; splitting a shared type into per-room types).
* Re-added deliberately after being omitted: the service verifies the target
* type belongs to the same property before writing, so this cannot re-point
* a room at another tenant's type.
*/
@ApiPropertyOptional({ description: 'Move the room to another room type (same property)' })
@IsOptional()
@IsUUID()
roomTypeId?: string;
}
126 changes: 124 additions & 2 deletions apps/api/src/modules/room/room-fk-ownership.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BadRequestException } from '@nestjs/common';
import { describe, it, expect, vi } from 'vitest';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { RoomService } from './room.service';
import { DRIZZLE } from '../../database/database.module';
Expand All @@ -11,6 +11,26 @@ import { DRIZZLE } from '../../database/database.module';
* (cross-tenant link the DB FK alone does not block).
*/
const A = 'aaaaaaaa-0000-4000-a000-000000000001';
const ROOM_ID = 'bbbbbbbb-0000-4000-a000-000000000001';
const RT_OLD = 'cccccccc-0000-4000-a000-000000000001';
const RT_NEW = 'dddddddd-0000-4000-a000-000000000001';

function selectQueue(results: unknown[][]) {
let i = 0;
return vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => {
const rows = results[i++] ?? [];
const thenable = Promise.resolve(rows);
return {
limit: vi.fn().mockResolvedValue(rows),
then: thenable.then.bind(thenable),
catch: thenable.catch.bind(thenable),
};
}),
}),
}));
}

describe('RoomService — createRoom cross-tenant FK ownership (audit #5)', () => {
it('rejects when dto.roomTypeId belongs to another property', async () => {
Expand Down Expand Up @@ -55,3 +75,105 @@ describe('RoomService — createRoom cross-tenant FK ownership (audit #5)', () =
expect(db.insert).toHaveBeenCalled();
});
});

describe('RoomService — updateRoom roomTypeId move', () => {
const vacantRoom = {
id: ROOM_ID,
propertyId: A,
roomTypeId: RT_OLD,
number: '101',
status: 'vacant_clean',
};

it('rejects cross-tenant target room type (404)', async () => {
const db: any = {
select: selectQueue([[vacantRoom], []]),
update: vi.fn(),
};
const mod = await Test.createTestingModule({
providers: [RoomService, { provide: DRIZZLE, useValue: db }],
}).compile();
const svc = mod.get(RoomService);

await expect(
svc.updateRoom(ROOM_ID, A, { roomTypeId: 'foreign-rt' } as any),
).rejects.toBeInstanceOf(NotFoundException);
expect(db.update).not.toHaveBeenCalled();
});

it('rejects move while room status is occupied', async () => {
const occupied = { ...vacantRoom, status: 'occupied' };
const db: any = {
select: selectQueue([[occupied], [{ id: RT_NEW }]]),
update: vi.fn(),
};
const mod = await Test.createTestingModule({
providers: [RoomService, { provide: DRIZZLE, useValue: db }],
}).compile();
const svc = mod.get(RoomService);

await expect(
svc.updateRoom(ROOM_ID, A, { roomTypeId: RT_NEW } as any),
).rejects.toBeInstanceOf(BadRequestException);
expect(db.update).not.toHaveBeenCalled();
});

it('rejects move while an assigned/in-house reservation links the room', async () => {
const db: any = {
select: selectQueue([[vacantRoom], [{ id: RT_NEW }], [{ id: 'res-1' }]]),
update: vi.fn(),
};
const mod = await Test.createTestingModule({
providers: [RoomService, { provide: DRIZZLE, useValue: db }],
}).compile();
const svc = mod.get(RoomService);

await expect(
svc.updateRoom(ROOM_ID, A, { roomTypeId: RT_NEW } as any),
).rejects.toBeInstanceOf(BadRequestException);
expect(db.update).not.toHaveBeenCalled();
});

it('allows same-property move when vacant and unlinked', async () => {
const updated = { ...vacantRoom, roomTypeId: RT_NEW };
const db: any = {
select: selectQueue([[vacantRoom], [{ id: RT_NEW }], []]),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updated]),
}),
}),
}),
};
const mod = await Test.createTestingModule({
providers: [RoomService, { provide: DRIZZLE, useValue: db }],
}).compile();
const svc = mod.get(RoomService);

const out = await svc.updateRoom(ROOM_ID, A, { roomTypeId: RT_NEW } as any);
expect(out.roomTypeId).toBe(RT_NEW);
expect(db.update).toHaveBeenCalled();
});

it('skips move guards when roomTypeId is unchanged', async () => {
const db: any = {
select: selectQueue([[vacantRoom]]),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ ...vacantRoom, floor: '2' }]),
}),
}),
}),
};
const mod = await Test.createTestingModule({
providers: [RoomService, { provide: DRIZZLE, useValue: db }],
}).compile();
const svc = mod.get(RoomService);

const out = await svc.updateRoom(ROOM_ID, A, { roomTypeId: RT_OLD, floor: '2' } as any);
expect(out.floor).toBe('2');
expect(db.update).toHaveBeenCalled();
});
});
52 changes: 50 additions & 2 deletions apps/api/src/modules/room/room.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { rooms, roomTypes } from '@telivityhaip/database';
import { eq, and, inArray } from 'drizzle-orm';
import { rooms, roomTypes, reservations } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
import { CreateRoomTypeDto } from './dto/create-room-type.dto';
import { CreateRoomDto } from './dto/create-room.dto';
import { UpdateRoomDto } from './dto/update-room.dto';

/** Stay statuses that pin a physical room — retyping would desync reservation.roomTypeId. */
const ROOM_TYPE_MOVE_BLOCKING_RESERVATION_STATUSES = [
'assigned',
'checked_in',
'stayover',
'due_out',
] as const;

@Injectable()
export class RoomService {
constructor(@Inject(DRIZZLE) private readonly db: any) {}
Expand Down Expand Up @@ -83,6 +91,46 @@ export class RoomService {
}

async updateRoom(id: string, propertyId: string, dto: UpdateRoomDto) {
const existing = await this.findRoomById(id, propertyId);

// FK ownership: a roomTypeId in the update is client-supplied — verify it
// belongs to THIS property before writing, or an update could re-point a
// room at another tenant's type (same rule as inbound-reservation mapping).
if (dto.roomTypeId && dto.roomTypeId !== existing.roomTypeId) {
const [type] = await this.db
.select({ id: roomTypes.id })
.from(roomTypes)
.where(and(eq(roomTypes.id, dto.roomTypeId), eq(roomTypes.propertyId, propertyId)));
if (!type) {
throw new NotFoundException(`Room type ${dto.roomTypeId} not found for this property`);
}

// Occupied / in-stay rooms must not change type — assign/check-in enforce
// room.roomTypeId === reservation.roomTypeId; a silent retype breaks that.
if (existing.status === 'occupied') {
throw new BadRequestException(
`Cannot move room ${existing.number} to another type while status is occupied`,
);
}

const [linked] = await this.db
.select({ id: reservations.id })
.from(reservations)
.where(
and(
eq(reservations.propertyId, propertyId),
eq(reservations.roomId, id),
inArray(reservations.status, [...ROOM_TYPE_MOVE_BLOCKING_RESERVATION_STATUSES]),
),
)
.limit(1);
if (linked) {
throw new BadRequestException(
`Cannot move room ${existing.number} to another type while it is linked to an active stay (${linked.id})`,
);
}
}

const [room] = await this.db
.update(rooms)
.set({ ...dto, updatedAt: new Date() })
Expand Down
4 changes: 2 additions & 2 deletions docs/test-stats.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"tests": 1504,
"tests": 1509,
"files": 214,
"updatedAt": "2026-08-13T04:06:48.382Z"
"updatedAt": "2026-08-13T05:06:18.343Z"
}
Loading