diff --git a/README.md b/README.md index 31a1a4f4e..029b2d9a3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Feature-specific MQTT topics and payloads are described in: - [Jukebox MQTT Notifications](doc/jukebox-mqtt-notifications.md) - [Daily Task MQTT Notifications](doc/daily-task-mqtt-notifications.md) - [Friendship MQTT Notifications](doc/friendship-mqtt-notifications.md) +- [Soulhome MQTT Notifications](doc/soulhome-mqtt-notifications.md) ## Getting started diff --git a/doc/mqtt-notification-contract.md b/doc/mqtt-notification-contract.md index 02c1247a6..9fee51bad 100644 --- a/doc/mqtt-notification-contract.md +++ b/doc/mqtt-notification-contract.md @@ -30,6 +30,7 @@ documented per feature. | `friendship` | `FRIEND_REQUEST_CREATED`, `FRIEND_REQUEST_ACCEPTED`, `FRIEND_REQUEST_REJECTED` | | `inactive_room` | `INACTIVE_ROOMS_REMOVED` | | `stock` | `STOCK_ITEM_ADDED`, `STOCK_ITEM_REMOVED` | +| `soulhome` | `SOULHOME_ROOM_ACTIVATED`, `SOULHOME_ROOM_DEACTIVATED`, `SOULHOME_ROOM_LAYOUT_UPDATED` | ## Frontend Routing @@ -61,12 +62,18 @@ switch (message.topic) { case 'stock': handleStock(message.type, message.payload); break; + case 'soulhome': + handleSoulhome(message.type, message.payload); + break; } ``` See [Stock MQTT Notifications](stock-mqtt-notifications.md) for the full stock-specific topic and payload contract. +See [Soulhome MQTT Notifications](soulhome-mqtt-notifications.md) for the full +Soul Home room notification topic and payload contract. + ## Clan Member Notifications Subscribe to clan member changes with: @@ -223,3 +230,34 @@ Payload: } } ``` + +## Soulhome Notifications + +Soul Home room changes are published to: + +```text +/clan/{clanId}/soulhome/{soulHomeId}/update +``` + +Use `/clan/{clanId}/soulhome/+/update` to subscribe to all Soul Home room +changes for a clan. + +Payload: + +```ts +{ + topic: 'soulhome', + type: + | 'SOULHOME_ROOM_ACTIVATED' + | 'SOULHOME_ROOM_DEACTIVATED' + | 'SOULHOME_ROOM_LAYOUT_UPDATED', + payload: { + topic: `/clan/${clanId}/soulhome/${soulHomeId}/update`, + clan_id: string, + soulHome_id: string, + mode?: 'single' | 'batch', + rooms: object[], + ts: number + } +} +``` diff --git a/doc/soulhome-mqtt-notifications.md b/doc/soulhome-mqtt-notifications.md new file mode 100644 index 000000000..17072f539 --- /dev/null +++ b/doc/soulhome-mqtt-notifications.md @@ -0,0 +1,216 @@ +# Soulhome MQTT Notifications + +The backend publishes clan Soul Home room state and layout notifications through +MQTT using the common topic format built by `NotificationSender`. + +Soulhome notifications describe committed room changes. Layout notifications +are sent only after `PUT /room` has successfully saved the room and furniture +changes. + +## Subscribe Topic + +Frontend clients that need Soul Home room changes for one clan should subscribe +to: + +```text +/clan/{clanId}/soulhome/+/update +``` + +The published topic is: + +```text +/clan/{clanId}/soulhome/{soulHomeId}/update +``` + +Where: + +- `{clanId}` is the clan whose Soul Home changed. +- `{soulHomeId}` is the Soul Home containing the changed room or rooms. + +## Payload + +All Soulhome notifications use the common MQTT envelope: + +```ts +{ + topic: 'soulhome', + type: + | 'SOULHOME_ROOM_ACTIVATED' + | 'SOULHOME_ROOM_DEACTIVATED' + | 'SOULHOME_ROOM_LAYOUT_UPDATED', + payload: SoulHomeRoomNotificationPayload +} +``` + +The inner `payload.topic` identifies the logical Soul Home room event for the +frontend. It is not the MQTT broker topic. + +```ts +type SoulHomeRoomNotificationPayload = { + topic: `/clan/${clanId}/soulhome/${soulHomeId}/update`, + clan_id: string, + soulHome_id: string, + mode?: 'single' | 'batch', + rooms: Array<{ + _id: string, + roomPosition?: number, + roomStatus?: 'Active' | 'Inactive', + deactivationTime?: string | null, + roomColour?: string, + wallpaper?: string, + floorType?: string, + furnitureChanged?: boolean + }>, + ts: number +} +``` + +## Room Activated + +Sent after one or more rooms have been activated. + +### Published Topic + +```text +/clan/{clanId}/soulhome/{soulHomeId}/update +``` + +### Event Type + +```text +SOULHOME_ROOM_ACTIVATED +``` + +### Payload Shape + +```ts +{ + topic: 'soulhome', + type: 'SOULHOME_ROOM_ACTIVATED', + payload: { + topic: `/clan/${clanId}/soulhome/${soulHomeId}/update`, + clan_id: string, + soulHome_id: string, + rooms: [ + { + _id: string, + roomPosition?: number, + roomStatus: 'Active', + deactivationTime: string + } + ], + ts: number + } +} +``` + +## Room Deactivated + +Sent after a room has been deactivated. + +### Published Topic + +```text +/clan/{clanId}/soulhome/{soulHomeId}/update +``` + +### Event Type + +```text +SOULHOME_ROOM_DEACTIVATED +``` + +### Payload Shape + +```ts +{ + topic: 'soulhome', + type: 'SOULHOME_ROOM_DEACTIVATED', + payload: { + topic: `/clan/${clanId}/soulhome/${soulHomeId}/update`, + clan_id: string, + soulHome_id: string, + rooms: [ + { + _id: string, + roomPosition?: number, + roomStatus: 'Inactive', + deactivationTime: string + } + ], + ts: number + } +} +``` + +## Room Layout Updated + +Sent after `PUT /room` has successfully saved room layout and furniture changes. + +`PUT /room` accepts either one room object or an array of room objects: + +```ts +UpdateRoomDto +``` + +```ts +UpdateRoomDto[] +``` + +Both request shapes publish the same event type. The `mode` field tells whether +the request updated one room or multiple rooms: + +- `single` means the request body was one room object. +- `batch` means the request body was an array. + +Only one MQTT message is published per successful `PUT /room` request. Batch +updates are not split into one message per room. + +### Published Topic + +```text +/clan/{clanId}/soulhome/{soulHomeId}/update +``` + +### Event Type + +```text +SOULHOME_ROOM_LAYOUT_UPDATED +``` + +### Payload Shape + +```ts +{ + topic: 'soulhome', + type: 'SOULHOME_ROOM_LAYOUT_UPDATED', + payload: { + topic: `/clan/${clanId}/soulhome/${soulHomeId}/update`, + clan_id: string, + soulHome_id: string, + mode: 'single' | 'batch', + rooms: [ + { + _id: string, + roomColour?: string, + wallpaper?: string, + floorType?: string, + furnitureChanged: boolean + } + ], + ts: number + } +} +``` + +## Frontend Handling + +Recommended frontend flow: + +1. Subscribe to `/clan/{clanId}/soulhome/+/update` when showing the Soul Home. +2. Use the top-level `type` field to route activation, deactivation, and layout + events. +3. Use `payload.rooms` as an array for both single and batch updates. +4. Use `payload.mode` for layout updates if the UI needs to distinguish one-room + saves from batch saves. +5. Refresh or patch the local Soul Home room cache after receiving the message. diff --git a/src/__tests__/clanInventory/modules/clanInventoryCommon.ts b/src/__tests__/clanInventory/modules/clanInventoryCommon.ts index b98671d77..564a6b1cd 100644 --- a/src/__tests__/clanInventory/modules/clanInventoryCommon.ts +++ b/src/__tests__/clanInventory/modules/clanInventoryCommon.ts @@ -27,6 +27,7 @@ import ClanHelperService from '../../../clan/utils/clanHelper.service'; import GameEventEmitter from '../../../gameEventsEmitter/gameEventEmitter'; import { RoomScheduler } from '../../../clanInventory/room/room.scheduler'; import RoomRemovalNotifier from '../../../clanInventory/room/roomRemoval.notifier'; +import RoomNotifier from '../../../clanInventory/room/room.notifier'; export default class ClanInventoryCommonModule { private constructor() {} @@ -59,6 +60,7 @@ export default class ClanInventoryCommonModule { ItemHelperService, StealTokenGuard, RoomService, + RoomNotifier, RoomHelperService, RoomScheduler, RoomRemovalNotifier, diff --git a/src/__tests__/clanInventory/room/RoomNotifier/notifications.test.ts b/src/__tests__/clanInventory/room/RoomNotifier/notifications.test.ts new file mode 100644 index 000000000..8c372965e --- /dev/null +++ b/src/__tests__/clanInventory/room/RoomNotifier/notifications.test.ts @@ -0,0 +1,153 @@ +import MQTTConnector from '../../../../common/service/notificator/MQTTConnector'; +import RoomNotifier, { + SoulHomeRoomNotificationType, +} from '../../../../clanInventory/room/room.notifier'; +import { RoomStatus } from '../../../../clanInventory/room/enum/roomStatus.enum'; + +jest.mock('../../../../common/service/notificator/MQTTConnector', () => ({ + getInstance: jest.fn(), +})); + +describe('RoomNotifier notifications', () => { + let publishMock: jest.Mock; + let notifier: RoomNotifier; + + beforeEach(() => { + publishMock = jest.fn(); + (MQTTConnector.getInstance as jest.Mock).mockReturnValue({ + publish: publishMock, + }); + jest.spyOn(Date, 'now').mockReturnValue(123456789); + notifier = new RoomNotifier(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('publishes room activation notifications to the soulhome topic', () => { + notifier.roomActivated({ + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + rooms: [ + { + _id: 'room-1', + roomStatus: RoomStatus.ACTIVE, + deactivationTime: '2026-09-09T12:00:00.000Z', + }, + ], + }); + + expect(publishMock).toHaveBeenCalledWith( + '/clan/clan-1/soulhome/soulhome-1/update', + expect.any(String), + ); + + const [, rawPayload] = publishMock.mock.calls[0]; + expect(JSON.parse(rawPayload)).toEqual({ + topic: 'soulhome', + type: SoulHomeRoomNotificationType.ROOM_ACTIVATED, + payload: { + topic: '/clan/clan-1/soulhome/soulhome-1/update', + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + rooms: [ + { + _id: 'room-1', + roomStatus: RoomStatus.ACTIVE, + deactivationTime: '2026-09-09T12:00:00.000Z', + }, + ], + ts: 123456789, + }, + }); + }); + + it('publishes room deactivation notifications to the soulhome topic', () => { + notifier.roomDeactivated({ + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + rooms: [ + { + _id: 'room-1', + roomStatus: RoomStatus.INACTIVE, + deactivationTime: '2026-09-09T12:00:00.000Z', + }, + ], + }); + + expect(publishMock).toHaveBeenCalledWith( + '/clan/clan-1/soulhome/soulhome-1/update', + expect.any(String), + ); + + const [, rawPayload] = publishMock.mock.calls[0]; + expect(JSON.parse(rawPayload)).toEqual({ + topic: 'soulhome', + type: SoulHomeRoomNotificationType.ROOM_DEACTIVATED, + payload: { + topic: '/clan/clan-1/soulhome/soulhome-1/update', + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + rooms: [ + { + _id: 'room-1', + roomStatus: RoomStatus.INACTIVE, + deactivationTime: '2026-09-09T12:00:00.000Z', + }, + ], + ts: 123456789, + }, + }); + }); + + it('publishes layout update notifications with single or batch mode', () => { + notifier.layoutUpdated({ + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + mode: 'batch', + rooms: [ + { + _id: 'room-1', + roomColour: 'green', + furnitureChanged: false, + }, + { + _id: 'room-2', + floorType: 'stone', + furnitureChanged: true, + }, + ], + }); + + expect(publishMock).toHaveBeenCalledWith( + '/clan/clan-1/soulhome/soulhome-1/update', + expect.any(String), + ); + + const [, rawPayload] = publishMock.mock.calls[0]; + expect(JSON.parse(rawPayload)).toEqual({ + topic: 'soulhome', + type: SoulHomeRoomNotificationType.ROOM_LAYOUT_UPDATED, + payload: { + topic: '/clan/clan-1/soulhome/soulhome-1/update', + clan_id: 'clan-1', + soulHome_id: 'soulhome-1', + mode: 'batch', + rooms: [ + { + _id: 'room-1', + roomColour: 'green', + furnitureChanged: false, + }, + { + _id: 'room-2', + floorType: 'stone', + furnitureChanged: true, + }, + ], + ts: 123456789, + }, + }); + }); +}); diff --git a/src/__tests__/clanInventory/room/RoomService/activateRoomsByIds.test.ts b/src/__tests__/clanInventory/room/RoomService/activateRoomsByIds.test.ts index 8b9c02821..8b06c2264 100644 --- a/src/__tests__/clanInventory/room/RoomService/activateRoomsByIds.test.ts +++ b/src/__tests__/clanInventory/room/RoomService/activateRoomsByIds.test.ts @@ -2,11 +2,19 @@ import ClanInventoryBuilderFactory from '../../data/clanInventoryBuilderFactory' import RoomModule from '../../modules/room.module'; import { getNonExisting_id } from '../../../test_utils/util/getNonExisting_id'; import { RoomService } from '../../../../clanInventory/room/room.service'; +import ClanBuilderFactory from '../../../clan/data/clanBuilderFactory'; +import ClanModule from '../../../clan/modules/clan.module'; +import SoulhomeModule from '../../modules/soulhome.module'; +import { RoomStatus } from '../../../../clanInventory/room/enum/roomStatus.enum'; describe('Room.activateRoomsByIds() test suite', () => { let roomService: RoomService; const roomBuilder = ClanInventoryBuilderFactory.getBuilder('Room'); const roomModel = RoomModule.getRoomModel(); + const clanBuilder = ClanBuilderFactory.getBuilder('Clan'); + const clanModel = ClanModule.getClanModel(); + const soulHomeBuilder = ClanInventoryBuilderFactory.getBuilder('SoulHome'); + const soulHomeModel = SoulhomeModule.getSoulhomeModel(); const soulHome_id = getNonExisting_id(); const existingRoom1 = roomBuilder.setSoulHomeId(soulHome_id).build(); @@ -42,4 +50,38 @@ describe('Room.activateRoomsByIds() test suite', () => { await expect(nullInput).rejects.toThrow(); await expect(undefinedInput).rejects.toThrow(); }); + + it('Should notify activated rooms grouped by SoulHome', async () => { + const roomActivatedSpy = jest + .spyOn(roomService['roomNotifier'], 'roomActivated') + .mockImplementation(); + const clan = clanBuilder.setId(getNonExisting_id()).build(); + const soulHome = soulHomeBuilder + .setId(getNonExisting_id()) + .setClanId(clan._id) + .build(); + const inactiveRoom = roomBuilder + .setSoulHomeId(soulHome._id) + .setRoomStatus(RoomStatus.INACTIVE) + .build(); + + await clanModel.create(clan); + await soulHomeModel.create(soulHome); + const createdRoom = await roomModel.create(inactiveRoom); + + await roomService.activateRoomsByIds([createdRoom._id.toString()], 1000); + + expect(roomActivatedSpy).toHaveBeenCalledWith({ + clan_id: clan._id.toString(), + soulHome_id: soulHome._id.toString(), + rooms: [ + { + _id: createdRoom._id.toString(), + roomPosition: inactiveRoom.roomPosition, + roomStatus: RoomStatus.ACTIVE, + deactivationTime: expect.any(Date), + }, + ], + }); + }); }); diff --git a/src/__tests__/clanInventory/room/RoomService/updateSoulHomeRooms.test.ts b/src/__tests__/clanInventory/room/RoomService/updateSoulHomeRooms.test.ts index 5467c230f..2d91e23eb 100644 --- a/src/__tests__/clanInventory/room/RoomService/updateSoulHomeRooms.test.ts +++ b/src/__tests__/clanInventory/room/RoomService/updateSoulHomeRooms.test.ts @@ -13,6 +13,7 @@ import { UpdateItemDto } from '../../../../clanInventory/item/dto/updateItem.dto import { ItemRotation } from '../../../../clanInventory/item/enum/itemRotation.enum'; import { ItemPosition } from '../../../../clanInventory/item/enum/itemPosition.enum'; import { ClanService } from '../../../../clan/clan.service'; +import { ObjectId } from 'mongodb'; describe('Room.updateSoulHomeRooms() test suite', () => { let roomService: RoomService; @@ -194,4 +195,101 @@ describe('Room.updateSoulHomeRooms() test suite', () => { expect(items[0].room_id).toBeNull(); expect(items[0].stock_id.toString()).toBe(existingStock._id.toString()); }); + + it('Should notify a single layout update when one room is updated', async () => { + const layoutUpdatedSpy = jest + .spyOn(roomService['roomNotifier'], 'layoutUpdated') + .mockImplementation(); + const singleUpdate: UpdateRoomDto = { + _id: existingRoom._id, + roomColour: 'green', + floorType: 'tile', + wallpaper: 'paper', + }; + + const [result, error] = await roomService.updateSoulHomeRooms(singleUpdate); + + expect(result).toBeTruthy(); + expect(error).toBeNull(); + expect(layoutUpdatedSpy).toHaveBeenCalledWith({ + clan_id: existingClan._id.toString(), + soulHome_id: existingSoulHome._id.toString(), + mode: 'single', + rooms: [ + { + _id: existingRoom._id.toString(), + roomColour: singleUpdate.roomColour, + wallpaper: singleUpdate.wallpaper, + floorType: singleUpdate.floorType, + furnitureChanged: false, + }, + ], + }); + }); + + it('Should notify one batch layout update when multiple rooms are updated', async () => { + const layoutUpdatedSpy = jest + .spyOn(roomService['roomNotifier'], 'layoutUpdated') + .mockImplementation(); + const secondRoom = { + ...existingRoom, + _id: new ObjectId().toString(), + roomPosition: 2, + }; + await roomModel.create(secondRoom); + + const batchUpdate: UpdateRoomDto[] = [ + { + _id: existingRoom._id, + roomColour: 'yellow', + }, + { + _id: secondRoom._id, + floorType: 'stone', + furniture: [], + }, + ]; + + const [result, error] = await roomService.updateSoulHomeRooms(batchUpdate); + + expect(result).toBeTruthy(); + expect(error).toBeNull(); + expect(layoutUpdatedSpy).toHaveBeenCalledTimes(1); + expect(layoutUpdatedSpy).toHaveBeenCalledWith({ + clan_id: existingClan._id.toString(), + soulHome_id: existingSoulHome._id.toString(), + mode: 'batch', + rooms: [ + { + _id: existingRoom._id.toString(), + roomColour: 'yellow', + wallpaper: undefined, + floorType: undefined, + furnitureChanged: false, + }, + { + _id: secondRoom._id.toString(), + roomColour: undefined, + wallpaper: undefined, + floorType: 'stone', + furnitureChanged: true, + }, + ], + }); + }); + + it('Should not notify a layout update when room update fails', async () => { + const layoutUpdatedSpy = jest + .spyOn(roomService['roomNotifier'], 'layoutUpdated') + .mockImplementation(); + const emptyUpdate: UpdateRoomDto = { + _id: existingRoom._id, + }; + + const [result, error] = await roomService.updateSoulHomeRooms(emptyUpdate); + + expect(result).toBeNull(); + expect(error).toContainSE_REQUIRED(); + expect(layoutUpdatedSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/clanInventory/clanInventory.module.ts b/src/clanInventory/clanInventory.module.ts index 8ae0d3d24..987e082dc 100644 --- a/src/clanInventory/clanInventory.module.ts +++ b/src/clanInventory/clanInventory.module.ts @@ -10,6 +10,7 @@ import { ItemSchema } from './item/item.schema'; import { ItemService } from './item/item.service'; import { ItemHelperService } from './item/itemHelper.service'; import { RoomController } from './room/room.controller'; +import RoomNotifier from './room/room.notifier'; import { RoomSchema } from './room/room.schema'; import { RoomService } from './room/room.service'; import RoomHelperService from './room/utils/room.helper.service'; @@ -62,7 +63,8 @@ import { RoomStartupFloorTypeRefreshService } from './room/roomStartupFloorTypeR ItemHelperService, StealTokenGuard, RoomService, - RoomStartupFloorTypeRefreshService, // will be remmoved in the future + RoomNotifier, + RoomStartupFloorTypeRefreshService, // will be removed in the future RoomHelperService, SoulHomeService, SoulHomeHelperService, @@ -74,6 +76,7 @@ import { RoomStartupFloorTypeRefreshService } from './room/roomStartupFloorTypeR ItemHelperService, StealTokenGuard, RoomService, + RoomNotifier, SoulHomeService, ], }) diff --git a/src/clanInventory/room/room.controller.ts b/src/clanInventory/room/room.controller.ts index 9d7f949fd..a0c8ca737 100644 --- a/src/clanInventory/room/room.controller.ts +++ b/src/clanInventory/room/room.controller.ts @@ -177,6 +177,6 @@ export class RoomController { ], ]; - this.service.activateRoomsByIds(allowedRooms, durationS ?? 21600); //6h is default + await this.service.activateRoomsByIds(allowedRooms, durationS ?? 21600); //6h is default } } diff --git a/src/clanInventory/room/room.notifier.ts b/src/clanInventory/room/room.notifier.ts new file mode 100644 index 000000000..cd23bcbed --- /dev/null +++ b/src/clanInventory/room/room.notifier.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationGroup } from '../../common/service/notificator/enum/NotificationGroup.enum'; +import { NotificationResource } from '../../common/service/notificator/enum/NotificationResource.enum'; +import { NotificationStatus } from '../../common/service/notificator/enum/NotificationStatus.enum'; +import NotificationSender from '../../common/service/notificator/NotificationSender'; +import { + buildMqttNotification, + MqttNotification, +} from '../../common/service/notificator/type/MqttNotification.type'; +import { RoomStatus } from './enum/roomStatus.enum'; + +export const SoulHomeRoomNotificationType = { + ROOM_ACTIVATED: 'SOULHOME_ROOM_ACTIVATED', + ROOM_DEACTIVATED: 'SOULHOME_ROOM_DEACTIVATED', + ROOM_LAYOUT_UPDATED: 'SOULHOME_ROOM_LAYOUT_UPDATED', +} as const; + +export type SoulHomeRoomNotificationType = + (typeof SoulHomeRoomNotificationType)[keyof typeof SoulHomeRoomNotificationType]; + +export type SoulHomeRoomLayoutMode = 'single' | 'batch'; + +export type SoulHomeRoomChangePayload = { + _id: string; + roomPosition?: number; + roomStatus?: RoomStatus; + deactivationTime?: Date | string | null; + roomColour?: string; + wallpaper?: string; + floorType?: string; + furnitureChanged?: boolean; +}; + +export type SoulHomeRoomNotificationInput = { + clan_id: string; + soulHome_id: string; + rooms: SoulHomeRoomChangePayload[]; +}; + +export type SoulHomeRoomLayoutNotificationInput = + SoulHomeRoomNotificationInput & { + mode: SoulHomeRoomLayoutMode; + }; + +export type SoulHomeRoomNotificationPayload = SoulHomeRoomNotificationInput & { + topic: string; + mode?: SoulHomeRoomLayoutMode; + ts: number; +}; + +@Injectable() +export default class RoomNotifier { + private readonly group = NotificationGroup.CLAN; + private readonly resource = 'soulhome' as NotificationResource; + + roomActivated(payload: SoulHomeRoomNotificationInput) { + this.sendSoulHomeNotification( + payload, + SoulHomeRoomNotificationType.ROOM_ACTIVATED, + ); + } + + roomDeactivated(payload: SoulHomeRoomNotificationInput) { + this.sendSoulHomeNotification( + payload, + SoulHomeRoomNotificationType.ROOM_DEACTIVATED, + ); + } + + layoutUpdated(payload: SoulHomeRoomLayoutNotificationInput) { + this.sendSoulHomeNotification( + payload, + SoulHomeRoomNotificationType.ROOM_LAYOUT_UPDATED, + ); + } + + private sendSoulHomeNotification( + payload: + | SoulHomeRoomNotificationInput + | SoulHomeRoomLayoutNotificationInput, + type: SoulHomeRoomNotificationType, + ) { + const topic = `/${this.group}/${payload.clan_id}/${this.resource}/${payload.soulHome_id}/update`; + const notificationPayload: SoulHomeRoomNotificationPayload = { + ...payload, + topic, + ts: Date.now(), + }; + const notification = buildMqttNotification( + this.resource, + type, + notificationPayload, + ); + + NotificationSender.buildNotification< + MqttNotification + >() + .addGroup(this.group, payload.clan_id) + .addResource(this.resource, payload.soulHome_id) + .send(NotificationStatus.UPDATE, notification); + } +} diff --git a/src/clanInventory/room/room.service.ts b/src/clanInventory/room/room.service.ts index 74d7ce3da..b3e4523ea 100644 --- a/src/clanInventory/room/room.service.ts +++ b/src/clanInventory/room/room.service.ts @@ -36,6 +36,7 @@ import { RoomStatus } from './enum/roomStatus.enum'; import { ClanService } from '../../clan/clan.service'; import { StockService } from '../stock/stock.service'; import { SEReason } from '../../common/service/basicService/SEReason'; +import RoomNotifier from './room.notifier'; @Injectable() export class RoomService { @@ -49,6 +50,7 @@ export class RoomService { private readonly clanService: ClanService, @Inject(forwardRef(() => StockService)) private readonly stockService: StockService, + private readonly roomNotifier: RoomNotifier, @InjectConnection() private readonly connection: Connection, ) { this.refsInModel = [ModelName.ITEM, ModelName.SOULHOME]; @@ -214,16 +216,49 @@ export class RoomService { /** * Activates specified rooms. * - * The method sets `deactivationTimestamp` field to current + specified duration. + * The method sets room status to active and `deactivationTime` to current + specified duration. * @param room_ids rooms to update * @param durationS how long in seconds room should remain active */ async activateRoomsByIds(room_ids: string[], durationS: number) { - const deactivationTimestamp = Date.now() + durationS * 1000; - const updateObject = { deactivationTimestamp }; + const deactivationTime = new Date(Date.now() + durationS * 1000); + const updateObject = { + deactivationTime, + roomStatus: RoomStatus.ACTIVE, + }; + const [roomsToActivate] = await this.basicService.readMany({ + filter: { _id: { $in: room_ids } }, + }); for (let i = 0, l = room_ids.length; i < l; i++) await this.basicService.updateOneById(room_ids[i], updateObject); + + if (!roomsToActivate) return; + + const roomsBySoulHomeId = roomsToActivate.reduce((groups, room) => { + const soulHomeId = room.soulHome_id.toString(); + const rooms = groups.get(soulHomeId) ?? []; + rooms.push(room); + groups.set(soulHomeId, rooms); + return groups; + }, new Map()); + + for (const [soulHomeId, rooms] of roomsBySoulHomeId.entries()) { + const [soulHome, soulHomeErrors] = + await this.soulHomeService.basicService.readOneById(soulHomeId); + if (soulHomeErrors || !soulHome) continue; + + this.roomNotifier.roomActivated({ + clan_id: soulHome.clan_id.toString(), + soulHome_id: soulHomeId, + rooms: rooms.map((room) => ({ + _id: room._id.toString(), + roomPosition: room.roomPosition, + roomStatus: RoomStatus.ACTIVE, + deactivationTime, + })), + }); + } } /** @@ -259,7 +294,8 @@ export class RoomService { ]; } - const rooms = Array.isArray(payload) ? payload : [payload]; + const isBatch = Array.isArray(payload); + const rooms = isBatch ? payload : [payload]; if (!rooms.length) return [ @@ -431,6 +467,21 @@ export class RoomService { await endTransaction(session); + if (soulHomeId && clanId) { + this.roomNotifier.layoutUpdated({ + clan_id: clanId, + soulHome_id: soulHomeId, + mode: isBatch ? 'batch' : 'single', + rooms: rooms.map((room) => ({ + _id: room._id, + roomColour: room.roomColour, + wallpaper: room.wallpaper, + floorType: room.floorType, + furnitureChanged: 'furniture' in room, + })), + }); + } + return [true, null]; } diff --git a/src/common/service/notificator/enum/MqttNotificationType.enum.ts b/src/common/service/notificator/enum/MqttNotificationType.enum.ts index 86b063ac0..c6c7b672c 100644 --- a/src/common/service/notificator/enum/MqttNotificationType.enum.ts +++ b/src/common/service/notificator/enum/MqttNotificationType.enum.ts @@ -33,4 +33,8 @@ export enum MqttNotificationType { STOCK_ITEM_ADDED = 'STOCK_ITEM_ADDED', STOCK_ITEM_REMOVED = 'STOCK_ITEM_REMOVED', + + SOULHOME_ROOM_ACTIVATED = 'SOULHOME_ROOM_ACTIVATED', + SOULHOME_ROOM_DEACTIVATED = 'SOULHOME_ROOM_DEACTIVATED', + SOULHOME_ROOM_LAYOUT_UPDATED = 'SOULHOME_ROOM_LAYOUT_UPDATED', } diff --git a/src/common/service/notificator/enum/NotificationResource.enum.ts b/src/common/service/notificator/enum/NotificationResource.enum.ts index 91e1248e3..9c87b2ce7 100644 --- a/src/common/service/notificator/enum/NotificationResource.enum.ts +++ b/src/common/service/notificator/enum/NotificationResource.enum.ts @@ -33,4 +33,8 @@ export enum NotificationResource { * Notification about clan member join/leave events */ MEMBER = 'member', + /** + * Notification about soulhome activation & deactivation and layout changes + */ + SOULHOME = 'soulhome', }