Compare commits

..

48 Commits

Author SHA1 Message Date
6c15ce77fe bug fix 2025-05-22 16:09:12 +03:00
a8bb161ee2 Merge pull request #382 from SyncrowIOT/DATA-monthly-yearly-procedure-occupancy
DATA-occupancy_count_per_month
2025-05-22 14:18:18 +03:00
fe891030aa input year, output month 2025-05-22 13:10:05 +03:00
f2e515b180 Merge pull request #377 from SyncrowIOT/SP-1561-be-get-all-projects-api-is-returning-internal-server-error
Sp 1561 be get all projects api is returning internal server error
2025-05-21 16:32:18 +03:00
f7fd96afa1 fix: remove unused params from get all projects api 2025-05-20 14:40:48 +03:00
5292271721 Merge pull request #376 from SyncrowIOT/SP-1555-implement-occupancy-api-for-analytics-dashboard
feat: add occupancy duration data retrieval and update procedures
2025-05-18 17:42:00 +03:00
180d16eeb1 feat: add occupancy duration data retrieval and update procedures 2025-05-18 17:40:41 +03:00
dca3db0c59 Merge pull request #373 from SyncrowIOT/DATA-space-occupancy-duration-procedure
DATA-space-occupancy-duration
2025-05-15 12:36:09 +03:00
56e78683b3 adjusted select 2025-05-15 12:28:38 +03:00
e575e51c4c Merge pull request #375 from SyncrowIOT/fix-duplication-community
feat: optimize getAllDevicesByCommunity to prevent duplicate space processing
2025-05-15 10:09:59 +03:00
8750da7e62 feat: optimize getAllDevicesByCommunity to prevent duplicate space processing 2025-05-15 10:09:29 +03:00
e3e9fe82fc Merge pull request #374 from SyncrowIOT/fix-duplication-devices-by-community
refactor: optimize device retrieval by avoiding duplicate space visits in getAllDevicesByCommunity
2025-05-15 10:08:31 +03:00
e253d1ca03 refactor: optimize device retrieval by avoiding duplicate space visits in getAllDevicesByCommunity 2025-05-15 10:08:02 +03:00
b50d7682f3 updates 2025-05-14 15:50:28 +03:00
1bb3803229 wording 2025-05-14 15:03:52 +03:00
92ee6ee951 bug fix 2025-05-14 14:11:36 +03:00
c06be4736c occupancy duration procedures 2025-05-14 13:34:29 +03:00
5cb4295f8a Merge pull request #372 from SyncrowIOT/fix-get-devices-by-product-type
feat: update DEVICE_SPACE_COMMUNITY route and add validation for spaceUuid and communityUuid in DTO
2025-05-14 13:00:46 +03:00
67331aa92a feat: update DEVICE_SPACE_COMMUNITY route and add validation for spaceUuid and communityUuid in DTO 2025-05-14 13:00:09 +03:00
7ec41f8311 Merge pull request #371 from SyncrowIOT/SP-1554-be-implement-get-all-devices-in-spaces-and-include-the-childs-for-analytics-dashboard
SP-1554-be-implement-get-all-devices-in-spaces-and-include-the-childs-for-analytics-dashboard
2025-05-13 03:20:08 +03:00
4aa3d04478 feat: update device and space services to use productType instead of deviceType and add query support for device retrieval by product type 2025-05-13 03:19:21 +03:00
799fcb6fb9 feat: add DEVICE_SPACE_COMMUNITY route and controller for device retrieval by space or community 2025-05-13 03:06:43 +03:00
921770ea79 Merge pull request #370 from SyncrowIOT/SP-1556-be-implement-occupancy-heat-map-api-for-analytics-dashboard
feat: add occupancy module with controller, service, and related DTOs for heat map data retrieval
2025-05-12 02:16:54 +03:00
7ec4171e1a feat: add occupancy module with controller, service, and related DTOs for heat map data retrieval 2025-05-12 02:15:57 +03:00
c0a6e9ab63 Merge pull request #368 from SyncrowIOT/DATA-space-occupancy-procedures
DATA-daily-space-occupancy-procedures
2025-05-12 01:02:38 +03:00
208281386d Merge pull request #369 from SyncrowIOT/SP-1552-be-implement-energy-consumption-include-all-device-api-for-analytics-dashboard
feat: add groupByDevice option to GetPowerClampBySpaceDto and update service logic
2025-05-11 02:00:51 +03:00
fb5084ba3a feat: add groupByDevice option to GetPowerClampBySpaceDto and update service logic 2025-05-11 01:59:26 +03:00
9c3abdd08a added on conflict 2025-05-09 16:25:30 +03:00
3535c1d8c5 month param 2025-05-09 15:47:05 +03:00
2ba5700fdd space occupancy API 2025-05-09 15:24:39 +03:00
1479c74423 Merge pull request #367 from SyncrowIOT/add-space-presence-sensor-detection-entity
refactor: rename presence sensor entities and update related references
2025-05-09 13:11:56 +03:00
8030644fee refactor: rename presence sensor entities and update related references 2025-05-09 13:11:14 +03:00
d43e860867 Merge pull request #366 from SyncrowIOT/DATA-param-removal
removed param from first CTE
2025-05-08 13:32:32 +03:00
f8269df3fb removed param from first CTE 2025-05-08 13:31:43 +03:00
c085514d27 Merge pull request #365 from SyncrowIOT/DATA-date-param-move
DATA-param-moved
2025-05-08 13:12:21 +03:00
fa3cb578df param moved 2025-05-08 13:11:29 +03:00
b4572beec2 Merge pull request #358 from SyncrowIOT/DATA-daily-occupancy-procedure
DATA-daily occupancy procedure
2025-05-08 12:55:42 +03:00
b3e86ec56f Merge pull request #362 from SyncrowIOT/fix-power-clamp-historical-data
Add endpoints and logic for fetching power clamp data by community or…
2025-05-07 23:09:58 +03:00
45b8cdcaae Add endpoints and logic for fetching power clamp data by community or space
- Introduced new API endpoints to retrieve power clamp historical data based on community or space UUID.
- Updated PowerClampController to handle requests with optional parameters for community and space.
- Enhanced PowerClampService to validate input and fetch devices accordingly.
- Created ResourceParamsDto to manage request parameters.
- Updated ControllerRoute with new action summaries and descriptions.
2025-05-07 23:09:01 +03:00
5ed59e4fcc Merge pull request #361 from SyncrowIOT/Implement-Total-Enargy-by-space-api
Implement total enargy by space api
2025-05-07 12:19:06 +03:00
91abfb41ab Implement month-based date formatting and filtering in PowerClamp service 2025-05-07 12:17:17 +03:00
d40fb7a762 Merge branch 'dev' into SP-1551-be-implement-total-energy-consumption-api-for-analytics-dashboard 2025-05-07 10:59:42 +03:00
71f795babe Merge pull request #360 from SyncrowIOT/DATA-energy_consumption_procedure_edits
DATA-daily_energy_consummed
2025-05-06 11:33:53 +03:00
0d48505eac month name 2025-05-06 11:30:23 +03:00
e538f2b829 grain change 2025-05-06 11:28:43 +03:00
23af8e9de3 Merge pull request #359 from SyncrowIOT/create-presence-sensor-daily-detection-entity
feat: add presence sensor module with entity, DTO, and repository
2025-05-04 22:36:52 +03:00
d197bf2bb4 feat: implement date formatting function and enhance PowerClampService with space-based data retrieval 2025-05-04 22:28:38 +03:00
2a1f1f52f6 feat: add presence sensor module with entity, DTO, and repository 2025-05-04 19:11:14 +03:00
63 changed files with 1794 additions and 42 deletions

View File

@ -498,6 +498,21 @@ export class ControllerRoute {
'Get power clamp historical data';
public static readonly GET_ENERGY_DESCRIPTION =
'This endpoint retrieves the historical data of a power clamp device based on the provided parameters.';
public static readonly GET_ENERGY_BY_COMMUNITY_OR_SPACE_SUMMARY =
'Get power clamp historical data by community or space';
public static readonly GET_ENERGY_BY_COMMUNITY_OR_SPACE_DESCRIPTION =
'This endpoint retrieves the historical data of power clamp devices based on the provided community or space UUID.';
};
};
static Occupancy = class {
public static readonly ROUTE = 'occupancy';
static ACTIONS = class {
public static readonly GET_OCCUPANCY_HEAT_MAP_SUMMARY =
'Get occupancy heat map data';
public static readonly GET_OCCUPANCY_HEAT_MAP_DESCRIPTION =
'This endpoint retrieves the occupancy heat map data based on the provided parameters.';
};
};
static DEVICE = class {
@ -609,6 +624,17 @@ export class ControllerRoute {
'This endpoint retrieves all devices in the system.';
};
};
static DEVICE_SPACE_COMMUNITY = class {
public static readonly ROUTE = 'devices-space-community';
static ACTIONS = class {
public static readonly GET_ALL_DEVICES_BY_SPACE_OR_COMMUNITY_WITH_RECURSIVE_CHILD_SUMMARY =
'Get all devices by space or community with recursive child';
public static readonly GET_ALL_DEVICES_BY_SPACE_OR_COMMUNITY_WITH_RECURSIVE_CHILD_DESCRIPTION =
'This endpoint retrieves all devices in the system by space or community with recursive child.';
};
};
static DEVICE_PERMISSION = class {
public static readonly ROUTE = 'device-permission';

View File

@ -0,0 +1,4 @@
export enum PresenceSensorEnum {
PRESENCE_STATE = 'presence_state',
SENSITIVITY = 'sensitivity',
}

View File

@ -51,6 +51,10 @@ import {
PowerClampHourlyEntity,
PowerClampMonthlyEntity,
} from '../modules/power-clamp/entities/power-clamp.entity';
import {
PresenceSensorDailyDeviceEntity,
PresenceSensorDailySpaceEntity,
} from '../modules/presence-sensor/entities';
@Module({
imports: [
TypeOrmModule.forRootAsync({
@ -109,6 +113,8 @@ import {
PowerClampHourlyEntity,
PowerClampDailyEntity,
PowerClampMonthlyEntity,
PresenceSensorDailyDeviceEntity,
PresenceSensorDailySpaceEntity,
],
namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),

View File

@ -10,6 +10,7 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Module({
providers: [
@ -21,6 +22,7 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
controllers: [DeviceStatusFirebaseController],
exports: [DeviceStatusFirebaseService, DeviceStatusLogRepository],

View File

@ -21,6 +21,8 @@ import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log
import { ProductType } from '@app/common/constants/product-type.enum';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { PowerClampEnergyEnum } from '@app/common/constants/power.clamp.enargy.enum';
import { PresenceSensorEnum } from '@app/common/constants/presence.sensor.enum';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Injectable()
export class DeviceStatusFirebaseService {
private tuya: TuyaContext;
@ -29,6 +31,7 @@ export class DeviceStatusFirebaseService {
private readonly configService: ConfigService,
private readonly deviceRepository: DeviceRepository,
private readonly powerClampService: PowerClampService,
private readonly occupancyService: OccupancyService,
private deviceStatusLogRepository: DeviceStatusLogRepository,
) {
const accessKey = this.configService.get<string>('auth-config.ACCESS_KEY');
@ -240,6 +243,26 @@ export class DeviceStatusFirebaseService {
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => occupancyCodes.has(status.code),
);
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
// Return the updated data
const snapshot: DataSnapshot = await get(dataRef);
return snapshot.val();

View File

@ -0,0 +1,38 @@
export function toDDMMYYYY(dateString?: string | null): string | null {
if (!dateString) return null;
// Ensure dateString is valid format YYYY-MM-DD
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateString)) {
throw new Error(
`Invalid date format: ${dateString}. Expected format is YYYY-MM-DD`,
);
}
const [year, month, day] = dateString.split('-');
return `${day}-${month}-${year}`;
}
export function toMMYYYY(dateString?: string | null): string | null {
if (!dateString) return null;
// Ensure dateString is valid format YYYY-MM
const regex = /^\d{4}-\d{2}$/;
if (!regex.test(dateString)) {
throw new Error(
`Invalid date format: ${dateString}. Expected format is YYYY-MM`,
);
}
const [year, month] = dateString.split('-');
return `${month}-${year}`;
}
export function filterByMonth(data: any[], monthDate: string) {
const [year, month] = monthDate.split('-').map(Number);
return data.filter((item) => {
const itemDate = new Date(item.date);
return (
itemDate.getUTCFullYear() === year && itemDate.getUTCMonth() + 1 === month
);
});
}

View File

@ -0,0 +1,68 @@
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { Injectable } from '@nestjs/common';
import { SqlLoaderService } from './sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
@Injectable()
export class OccupancyService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
private readonly deviceRepository: DeviceRepository,
) {}
async updateOccupancySensorHistoricalDurationData(
deviceUuid: string,
): Promise<void> {
try {
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid },
relations: ['spaceDevice'],
});
await this.executeProcedure(
'fact_daily_space_occupancy_duration',
'procedure_update_daily_space_occupancy_duration',
[dateStr, device.spaceDevice?.uuid],
);
} catch (err) {
console.error('Failed to insert or update occupancy duration data:', err);
throw err;
}
}
async updateOccupancySensorHistoricalData(deviceUuid: string): Promise<void> {
try {
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid },
relations: ['spaceDevice'],
});
await this.executeProcedure(
'fact_space_occupancy_count',
'procedure_update_fact_space_occupancy',
[dateStr, device.spaceDevice?.uuid],
);
} catch (err) {
console.error('Failed to insert or update occupancy data:', err);
throw err;
}
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<void> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -46,16 +46,15 @@ export class PowerClampService {
procedureFileName: string,
params: (string | number | null)[],
): Promise<void> {
const query = this.loadQuery(procedureFileName);
const query = this.loadQuery(
'fact_device_energy_consumed',
procedureFileName,
);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
}
private loadQuery(fileName: string): string {
return this.sqlLoader.loadQuery(
'fact_device_energy_consumed',
fileName,
SQL_PROCEDURES_PATH,
);
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -18,6 +18,7 @@ import { SpaceEntity } from '../../space/entities/space.entity';
import { SubspaceEntity } from '../../space/entities/subspace/subspace.entity';
import { NewTagEntity } from '../../tag';
import { PowerClampHourlyEntity } from '../../power-clamp/entities/power-clamp.entity';
import { PresenceSensorDailyDeviceEntity } from '../../presence-sensor/entities';
@Entity({ name: 'device' })
@Unique(['deviceTuyaUuid'])
@ -82,6 +83,8 @@ export class DeviceEntity extends AbstractEntity<DeviceDto> {
public tag: NewTagEntity;
@OneToMany(() => PowerClampHourlyEntity, (powerClamp) => powerClamp.device)
powerClampHourly: PowerClampHourlyEntity[];
@OneToMany(() => PresenceSensorDailyDeviceEntity, (sensor) => sensor.device)
presenceSensorDaily: PresenceSensorDailyDeviceEntity[];
constructor(partial: Partial<DeviceEntity>) {
super();
Object.assign(this, partial);

View File

@ -0,0 +1 @@
export * from './presence-sensor.dto';

View File

@ -0,0 +1,27 @@
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
export class PresenceSensorDto {
@IsString()
@IsNotEmpty()
public uuid: string;
@IsString()
@IsNotEmpty()
public deviceUuid: string;
@IsString()
@IsNotEmpty()
public eventDate: string;
@IsNumber()
@IsNotEmpty()
public CountMotionDetected: number;
@IsNumber()
@IsNotEmpty()
public CountPresenceDetected: number;
@IsNumber()
@IsNotEmpty()
public CountTotalPresenceDetected: number;
}

View File

@ -0,0 +1 @@
export * from './presence-sensor.entity';

View File

@ -0,0 +1,58 @@
import { Column, Entity, ManyToOne, Unique } from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { PresenceSensorDto } from '../dtos';
import { DeviceEntity } from '../../device/entities/device.entity';
import { SpaceEntity } from '../../space/entities/space.entity';
@Entity({ name: 'presence-sensor-daily-device-detection' })
@Unique(['deviceUuid', 'eventDate'])
export class PresenceSensorDailyDeviceEntity extends AbstractEntity<PresenceSensorDto> {
@Column({ nullable: false })
public deviceUuid: string;
@Column({ nullable: false, type: 'date' })
public eventDate: string;
@Column({ nullable: false })
public CountMotionDetected: number;
@Column({ nullable: false })
public CountPresenceDetected: number;
@Column({ nullable: false })
public CountTotalPresenceDetected: number;
@ManyToOne(() => DeviceEntity, (device) => device.presenceSensorDaily)
device: DeviceEntity;
constructor(partial: Partial<PresenceSensorDailyDeviceEntity>) {
super();
Object.assign(this, partial);
}
}
@Entity({ name: 'presence-sensor-daily-space-detection' })
@Unique(['spaceUuid', 'eventDate'])
export class PresenceSensorDailySpaceEntity extends AbstractEntity<PresenceSensorDto> {
@Column({ nullable: false })
public spaceUuid: string;
@Column({ nullable: false, type: 'date' })
public eventDate: string;
@Column({ nullable: false })
public CountMotionDetected: number;
@Column({ nullable: false })
public CountPresenceDetected: number;
@Column({ nullable: false })
public CountTotalPresenceDetected: number;
@ManyToOne(() => SpaceEntity, (space) => space.presenceSensorDaily)
space: SpaceEntity;
constructor(partial: Partial<PresenceSensorDailySpaceEntity>) {
super();
Object.assign(this, partial);
}
}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
PresenceSensorDailyDeviceEntity,
PresenceSensorDailySpaceEntity,
} from './entities/presence-sensor.entity';
@Module({
providers: [],
exports: [],
controllers: [],
imports: [
TypeOrmModule.forFeature([
PresenceSensorDailyDeviceEntity,
PresenceSensorDailySpaceEntity,
]),
],
})
export class PresenceSensorRepositoryModule {}

View File

@ -0,0 +1 @@
export * from './presence-sensor.repository';

View File

@ -0,0 +1,19 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import {
PresenceSensorDailyDeviceEntity,
PresenceSensorDailySpaceEntity,
} from '../entities';
@Injectable()
export class PresenceSensorDailyDeviceRepository extends Repository<PresenceSensorDailyDeviceEntity> {
constructor(private dataSource: DataSource) {
super(PresenceSensorDailyDeviceEntity, dataSource.createEntityManager());
}
}
@Injectable()
export class PresenceSensorDailySpaceRepository extends Repository<PresenceSensorDailySpaceEntity> {
constructor(private dataSource: DataSource) {
super(PresenceSensorDailySpaceEntity, dataSource.createEntityManager());
}
}

View File

@ -10,6 +10,7 @@ import { SpaceModelEntity } from '../../space-model';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -111,6 +112,9 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
)
public productAllocations: SpaceProductAllocationEntity[];
@OneToMany(() => PresenceSensorDailySpaceEntity, (sensor) => sensor.space)
presenceSensorDaily: PresenceSensorDailySpaceEntity[];
constructor(partial: Partial<SpaceEntity>) {
super();
Object.assign(this, partial);

View File

@ -0,0 +1,116 @@
-- Step 1: Get device presence events with previous timestamps
WITH start_date AS (
SELECT
d.uuid AS device_id,
d.space_device_uuid AS space_id,
l.value,
l.event_time::timestamp AS event_time,
LAG(l.event_time::timestamp) OVER (PARTITION BY d.uuid ORDER BY l.event_time) AS prev_timestamp
FROM device d
LEFT JOIN "device-status-log" l
ON d.uuid = l.device_id
LEFT JOIN product p
ON p.uuid = d.product_device_uuid
WHERE p.cat_name = 'hps'
AND l.code = 'presence_state'
),
-- Step 2: Identify periods when device reports "none"
device_none_periods AS (
SELECT
space_id,
device_id,
event_time AS empty_from,
LEAD(event_time) OVER (PARTITION BY device_id ORDER BY event_time) AS empty_until
FROM start_date
WHERE value = 'none'
),
-- Step 3: Clip the "none" periods to the edges of each day
clipped_device_none_periods AS (
SELECT
space_id,
GREATEST(empty_from, DATE_TRUNC('day', empty_from)) AS clipped_from,
LEAST(empty_until, DATE_TRUNC('day', empty_until) + INTERVAL '1 day') AS clipped_until
FROM device_none_periods
WHERE empty_until IS NOT NULL
),
-- Step 4: Break multi-day periods into daily intervals
generated_daily_intervals AS (
SELECT
space_id,
gs::date AS day,
GREATEST(clipped_from, gs) AS interval_start,
LEAST(clipped_until, gs + INTERVAL '1 day') AS interval_end
FROM clipped_device_none_periods,
LATERAL generate_series(DATE_TRUNC('day', clipped_from), DATE_TRUNC('day', clipped_until), INTERVAL '1 day') AS gs
),
-- Step 5: Merge overlapping or adjacent intervals per day
merged_intervals AS (
SELECT
space_id,
day,
interval_start,
interval_end
FROM (
SELECT
space_id,
day,
interval_start,
interval_end,
LAG(interval_end) OVER (PARTITION BY space_id, day ORDER BY interval_start) AS prev_end
FROM generated_daily_intervals
) sub
WHERE prev_end IS NULL OR interval_start > prev_end
),
-- Step 6: Sum up total missing seconds (device reported "none") per day
missing_seconds_per_day AS (
SELECT
space_id,
day AS missing_date,
SUM(EXTRACT(EPOCH FROM (interval_end - interval_start))) AS total_missing_seconds
FROM merged_intervals
GROUP BY space_id, day
),
-- Step 7: Calculate total occupied time per day (86400 - missing)
occupied_seconds_per_day AS (
SELECT
space_id,
missing_date as event_date,
86400 - total_missing_seconds AS total_occupied_seconds,
(86400 - total_missing_seconds)/86400*100 as occupancy_prct
FROM missing_seconds_per_day
)
-- Final Output
, final_data as (
SELECT space_id,
event_date,
total_occupied_seconds,
occupancy_prct
FROM occupied_seconds_per_day
ORDER BY 1,2
)
INSERT INTO public."space-daily-occupancy-duration" (
space_uuid,
event_date,
occupied_seconds,
occupancy_percentage
)
select space_id,
event_date,
total_occupied_seconds,
occupancy_prct
FROM final_data
ON CONFLICT (space_uuid, event_date) DO UPDATE
SET
occupancy_percentage = EXCLUDED.occupancy_percentage,
occupied_seconds = EXCLUDED.occupied_seconds;

View File

@ -0,0 +1,17 @@
WITH params AS (
SELECT
$1::uuid AS space_uuid,
TO_DATE(NULLIF($2, ''), 'YYYY-MM') AS event_month
)
SELECT sdo.space_uuid,
event_date,
occupancy_percentage,
occupied_seconds
FROM public."space-daily-occupancy-duration" as sdo
JOIN params P ON true
where (sdo.space_uuid = P.space_uuid
OR P.event_month IS null)
AND TO_CHAR(sdo.event_date, 'YYYY-MM') = TO_CHAR(P.event_month, 'YYYY-MM')
ORDER BY sdo.space_uuid, sdo.event_date;

View File

@ -0,0 +1,116 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date,
$2::uuid AS space_id
)
, start_date AS (
SELECT
d.uuid AS device_id,
d.space_device_uuid AS space_id,
l.value,
l.event_time::timestamp AS event_time,
LAG(l.event_time::timestamp) OVER (PARTITION BY d.uuid ORDER BY l.event_time) AS prev_timestamp
FROM device d
LEFT JOIN "device-status-log" l
ON d.uuid = l.device_id
LEFT JOIN product p
ON p.uuid = d.product_device_uuid
WHERE p.cat_name = 'hps'
AND l.code = 'presence_state'
)
, device_none_periods AS (
SELECT
space_id,
device_id,
event_time AS empty_from,
LEAD(event_time) OVER (PARTITION BY device_id ORDER BY event_time) AS empty_until
FROM start_date
WHERE value = 'none'
)
, clipped_device_none_periods AS (
SELECT
space_id,
GREATEST(empty_from, DATE_TRUNC('day', empty_from)) AS clipped_from,
LEAST(empty_until, DATE_TRUNC('day', empty_until) + INTERVAL '1 day') AS clipped_until
FROM device_none_periods
WHERE empty_until IS NOT NULL
)
, generated_daily_intervals AS (
SELECT
space_id,
gs::date AS day,
GREATEST(clipped_from, gs) AS interval_start,
LEAST(clipped_until, gs + INTERVAL '1 day') AS interval_end
FROM clipped_device_none_periods,
LATERAL generate_series(DATE_TRUNC('day', clipped_from), DATE_TRUNC('day', clipped_until), INTERVAL '1 day') AS gs
)
, merged_intervals AS (
SELECT
space_id,
day,
interval_start,
interval_end
FROM (
SELECT
space_id,
day,
interval_start,
interval_end,
LAG(interval_end) OVER (PARTITION BY space_id, day ORDER BY interval_start) AS prev_end
FROM generated_daily_intervals
) sub
WHERE prev_end IS NULL OR interval_start > prev_end
)
, missing_seconds_per_day AS (
SELECT
space_id,
day AS missing_date,
SUM(EXTRACT(EPOCH FROM (interval_end - interval_start))) AS total_missing_seconds
FROM merged_intervals
GROUP BY space_id, day
)
, occupied_seconds_per_day AS (
SELECT
space_id,
missing_date as event_date,
86400 - total_missing_seconds AS total_occupied_seconds,
(86400 - total_missing_seconds)/86400*100 as occupancy_percentage
FROM missing_seconds_per_day
)
, final_data as (
SELECT
occupied_seconds_per_day.space_id,
occupied_seconds_per_day.event_date,
total_occupied_seconds,
occupancy_percentage
FROM occupied_seconds_per_day
JOIN params p
ON p.space_id = occupied_seconds_per_day.space_id
AND p.event_date = occupied_seconds_per_day.event_date
)
INSERT INTO public."space-daily-occupancy-duration" (
space_uuid,
event_date,
occupied_seconds,
occupancy_percentage
)
SELECT
space_id,
event_date,
total_occupied_seconds,
occupancy_percentage
FROM final_data
ON CONFLICT (space_uuid, event_date) DO UPDATE
SET
occupied_seconds = EXCLUDED.occupied_seconds,
occupancy_percentage = EXCLUDED.occupancy_percentage;

View File

@ -11,7 +11,7 @@ WITH params AS (
A.count_motion_detected,
A.count_presence_detected,
A.count_total_presence_detected
FROM public."presence-sensor-daily-detection" AS A
FROM public."presence-sensor-daily-device-detection" AS A
JOIN params P ON TRUE
WHERE A.device_uuid::text = ANY(P.device_ids)
AND (P.month IS NULL

View File

@ -0,0 +1,21 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($2, ''), 'YYYY') AS year,
string_to_array(NULLIF($4, ''), ',') AS device_ids
)
SELECT
A.device_uuid,
TO_CHAR(date_trunc('month', A.event_date), 'YYYY-MM') AS event_month,
SUM(A.count_motion_detected) AS total_motion_detected,
SUM(A.count_presence_detected) AS total_presence_detected,
SUM(A.count_total_presence_detected) AS total_overall_presence
FROM public."presence-sensor-daily-device-detection" AS A
JOIN params P ON TRUE
WHERE A.device_uuid::text = ANY(P.device_ids)
AND (
P.year IS NULL
OR date_trunc('year', A.event_date) = P.year
)
GROUP BY 1,2
ORDER BY 1,2;

View File

@ -26,6 +26,7 @@ device_logs AS (
ON device.uuid = "device-status-log".device_id
LEFT JOIN product
ON product.uuid = device.product_device_uuid
JOIN params P ON TRUE
WHERE product.cat_name = 'hps'
AND "device-status-log".code = 'presence_state'
AND device.uuid::text = P.device_id
@ -93,7 +94,7 @@ daily_aggregates AS (
GROUP BY device_id, event_date
)
INSERT INTO public."presence-sensor-daily-detection" (
INSERT INTO public."presence-sensor-daily-device-detection" (
device_uuid,
event_date,
count_motion_detected,

View File

@ -0,0 +1,20 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'MM-YYYY') AS month,
string_to_array(NULLIF($2, ''), ',') AS device_ids
)
SELECT
A.date,
SUM(A.energy_consumed_kW::numeric) AS total_energy_consumed_KW,
SUM(A.energy_consumed_A::numeric) AS total_energy_consumed_A,
SUM(A.energy_consumed_B::numeric) AS total_energy_consumed_B,
SUM(A.energy_consumed_C::numeric) AS total_energy_consumed_C
FROM public."power-clamp-energy-consumed-daily" AS A
JOIN public.device AS B
ON A.device_uuid::TEXT = B."uuid"::TEXT
JOIN params P ON TRUE
WHERE B."uuid"::TEXT = ANY(P.device_ids)
AND (P.month IS NULL OR date_trunc('month', A.date)= P.month)
GROUP BY A.date
ORDER BY A.date;

View File

@ -1,8 +1,8 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($2, ''), 'DD-MM-YYYY') AS start_date,
TO_DATE(NULLIF($3, ''), 'DD-MM-YYYY') AS end_date,
string_to_array(NULLIF($4, ''), ',') AS device_ids
TO_DATE(NULLIF($1, ''), 'DD-MM-YYYY') AS start_date,
TO_DATE(NULLIF($2, ''), 'DD-MM-YYYY') AS end_date,
string_to_array(NULLIF($3, ''), ',') AS device_ids
)
SELECT TO_CHAR(A.date, 'MM-YYYY') AS month,

View File

@ -0,0 +1,15 @@
WITH params AS (
SELECT
$1::uuid AS space_id,
TO_DATE(NULLIF($2, ''), 'YYYY') AS event_year
)
SELECT psdsd.*
FROM public."presence-sensor-daily-space-detection" psdsd
JOIN params P ON true
WHERE psdsd.space_uuid = P.space_id
AND (
P.event_year IS NULL
OR TO_CHAR(psdsd.event_date, 'YYYY') = TO_CHAR(P.event_year, 'YYYY')
)
ORDER BY space_uuid, event_date

View File

@ -0,0 +1,100 @@
WITH device_logs AS (
SELECT
device.uuid AS device_id,
device.space_device_uuid AS space_id,
"device-status-log".event_time::timestamp AS event_time,
"device-status-log".value,
LAG("device-status-log".value)
OVER (PARTITION BY device.uuid ORDER BY "device-status-log".event_time) AS prev_value
FROM device
LEFT JOIN "device-status-log"
ON device.uuid = "device-status-log".device_id
LEFT JOIN product
ON product.uuid = device.product_device_uuid
WHERE product.cat_name = 'hps'
AND "device-status-log".code = 'presence_state'
),
-- 1. All 'none' → presence or motion
presence_transitions AS (
SELECT
space_id,
event_time,
event_time::date AS event_date,
value
FROM device_logs
WHERE (value = 'motion' OR value = 'presence') AND prev_value = 'none'
),
-- 2. Cluster events per space_id within 30s
clustered_events AS (
SELECT
space_id,
event_time,
event_date,
value,
SUM(new_cluster_flag) OVER (PARTITION BY space_id ORDER BY event_time) AS cluster_id
FROM (
SELECT *,
CASE
WHEN event_time - LAG(event_time) OVER (PARTITION BY space_id ORDER BY event_time) > INTERVAL '30 seconds'
THEN 1 ELSE 0
END AS new_cluster_flag
FROM presence_transitions
) marked
),
-- 3. Determine dominant type (motion vs presence) per cluster
cluster_type AS (
SELECT
space_id,
event_date,
cluster_id,
COUNT(*) FILTER (WHERE value = 'motion') AS motion_count,
COUNT(*) FILTER (WHERE value = 'presence') AS presence_count,
CASE
WHEN COUNT(*) FILTER (WHERE value = 'motion') > COUNT(*) FILTER (WHERE value = 'presence') THEN 'motion'
ELSE 'presence'
END AS dominant_type
FROM clustered_events
GROUP BY space_id, event_date, cluster_id
),
-- 4. Count clusters by dominant type
summary AS (
SELECT
space_id,
event_date,
COUNT(*) FILTER (WHERE dominant_type = 'motion') AS count_motion_detected,
COUNT(*) FILTER (WHERE dominant_type = 'presence') AS count_presence_detected,
COUNT(*) AS count_total_presence_detected
FROM cluster_type
GROUP BY space_id, event_date
)
-- 5. Output
, final_table as (
SELECT *
FROM summary
ORDER BY space_id, event_date)
INSERT INTO public."presence-sensor-daily-space-detection" (
space_uuid,
event_date,
count_motion_detected,
count_presence_detected,
count_total_presence_detected
)
SELECT
space_id,
event_date,
count_motion_detected,
count_presence_detected,
count_total_presence_detected
FROM final_table
ON CONFLICT (space_uuid, event_date) DO UPDATE
SET
count_motion_detected = EXCLUDED.count_motion_detected,
count_presence_detected = EXCLUDED.count_presence_detected,
count_total_presence_detected = EXCLUDED.count_total_presence_detected;

View File

@ -0,0 +1,113 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date,
$2::uuid AS space_id
),
device_logs AS (
SELECT
device.uuid AS device_id,
device.space_device_uuid AS space_id,
"device-status-log".event_time::timestamp AS event_time,
"device-status-log".value,
LAG("device-status-log".value)
OVER (PARTITION BY device.uuid ORDER BY "device-status-log".event_time) AS prev_value
FROM device
LEFT JOIN "device-status-log"
ON device.uuid = "device-status-log".device_id
LEFT JOIN product
ON product.uuid = device.product_device_uuid
WHERE product.cat_name = 'hps'
AND "device-status-log".code = 'presence_state'
),
-- 1. All 'none' → presence or motion
presence_transitions AS (
SELECT
space_id,
event_time,
event_time::date AS event_date,
value
FROM device_logs
WHERE (value = 'motion' OR value = 'presence') AND prev_value = 'none'
),
-- 2. Cluster events per space_id within 30s
clustered_events AS (
SELECT
space_id,
event_time,
event_date,
value,
SUM(new_cluster_flag) OVER (PARTITION BY space_id ORDER BY event_time) AS cluster_id
FROM (
SELECT *,
CASE
WHEN event_time - LAG(event_time) OVER (PARTITION BY space_id ORDER BY event_time) > INTERVAL '30 seconds'
THEN 1 ELSE 0
END AS new_cluster_flag
FROM presence_transitions
) marked
),
-- 3. Determine dominant type (motion vs presence) per cluster
cluster_type AS (
SELECT
space_id,
event_date,
cluster_id,
COUNT(*) FILTER (WHERE value = 'motion') AS motion_count,
COUNT(*) FILTER (WHERE value = 'presence') AS presence_count,
CASE
WHEN COUNT(*) FILTER (WHERE value = 'motion') > COUNT(*) FILTER (WHERE value = 'presence') THEN 'motion'
ELSE 'presence'
END AS dominant_type
FROM clustered_events
GROUP BY space_id, event_date, cluster_id
),
-- 4. Count clusters by dominant type
summary AS (
SELECT
space_id,
event_date,
COUNT(*) FILTER (WHERE dominant_type = 'motion') AS count_motion_detected,
COUNT(*) FILTER (WHERE dominant_type = 'presence') AS count_presence_detected,
COUNT(*) AS count_total_presence_detected
FROM cluster_type
GROUP BY space_id, event_date
)
-- 5. Output
, final_table as (
SELECT summary.space_id,
summary.event_date,
count_motion_detected,
count_presence_detected,
count_total_presence_detected
FROM summary
JOIN params P ON true
where summary.space_id = P.space_id
and (P.event_date IS NULL or summary.event_date::date = P.event_date)
ORDER BY space_id, event_date)
INSERT INTO public."presence-sensor-daily-space-detection" (
space_uuid,
event_date,
count_motion_detected,
count_presence_detected,
count_total_presence_detected
)
SELECT
space_id,
event_date,
count_motion_detected,
count_presence_detected,
count_total_presence_detected
FROM final_table
ON CONFLICT (space_uuid, event_date) DO UPDATE
SET
count_motion_detected = EXCLUDED.count_motion_detected,
count_presence_detected = EXCLUDED.count_presence_detected,
count_total_presence_detected = EXCLUDED.count_total_presence_detected;

View File

@ -85,7 +85,7 @@ daily_aggregate AS (
GROUP BY device_id, event_date
)
INSERT INTO public."presence-sensor-daily-detection" (
INSERT INTO public."presence-sensor-daily-device-detection" (
device_uuid,
event_date,
count_motion_detected,

View File

@ -37,12 +37,13 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { HealthModule } from './health/health.module';
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
import { OccupancyModule } from './occupancy/occupancy.module';
@Module({
imports: [
ConfigModule.forRoot({
load: config,
}),
/* ThrottlerModule.forRoot({
/* ThrottlerModule.forRoot({
throttlers: [{ ttl: 100000, limit: 30 }],
}), */
WinstonModule.forRoot(winstonLoggerOptions),
@ -77,13 +78,14 @@ import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston
DeviceCommissionModule,
PowerClampModule,
HealthModule,
OccupancyModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
/* {
/* {
provide: APP_GUARD,
useClass: ThrottlerGuard,
}, */

View File

@ -18,6 +18,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories/project.repository';
import { AutomationSpaceController } from './controllers/automation-space.controller';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Module({
imports: [ConfigModule, SpaceRepositoryModule, DeviceStatusFirebaseModule],
@ -35,6 +36,7 @@ import { AutomationSpaceController } from './controllers/automation-space.contro
SceneDeviceRepository,
AutomationRepository,
ProjectRepository,
CommunityRepository,
],
exports: [AutomationService],
})

View File

@ -28,6 +28,7 @@ import {
} from '@app/common/modules/power-clamp/repositories';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Module({
imports: [ConfigModule, SpaceRepositoryModule],
@ -55,6 +56,7 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
exports: [],
})

View File

@ -63,6 +63,7 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Module({
imports: [ConfigModule, SpaceRepositoryModule, UserRepositoryModule],
@ -114,6 +115,7 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
exports: [CommunityService, SpacePermissionService],
})

View File

@ -1,4 +1,9 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import {
Injectable,
HttpException,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@ -16,6 +21,8 @@ import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant';
import { ILike, In, Not } from 'typeorm';
import { SpaceService } from 'src/space/services';
import { SpaceRepository } from '@app/common/modules/space';
import { DeviceEntity } from '@app/common/modules/device/entities';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
@Injectable()
export class CommunityService {
@ -303,4 +310,53 @@ export class CommunityService {
);
}
}
async getAllDevicesByCommunity(
communityUuid: string,
): Promise<DeviceEntity[]> {
const community = await this.communityRepository.findOne({
where: { uuid: communityUuid },
relations: [
'spaces',
'spaces.children',
'spaces.devices',
'spaces.devices.productDevice',
],
});
if (!community) {
throw new NotFoundException('Community not found');
}
const allDevices: DeviceEntity[] = [];
const visitedSpaceUuids = new Set<string>();
// Recursive fetch function with visited check
const fetchSpaceDevices = async (space: SpaceEntity) => {
if (visitedSpaceUuids.has(space.uuid)) return;
visitedSpaceUuids.add(space.uuid);
if (space.devices?.length) {
allDevices.push(...space.devices);
}
if (space.children?.length) {
for (const child of space.children) {
const fullChild = await this.spaceRepository.findOne({
where: { uuid: child.uuid },
relations: ['children', 'devices', 'devices.productDevice'],
});
if (fullChild) {
await fetchSpaceDevices(fullChild);
}
}
}
};
for (const space of community.spaces) {
await fetchSpaceDevices(space);
}
return allDevices;
}
}

View File

@ -0,0 +1,52 @@
import { DeviceService } from '../services/device.service';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
import { GetDevicesBySpaceOrCommunityDto } from '../dtos';
@ApiTags('Device Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.DEVICE_SPACE_COMMUNITY.ROUTE,
})
export class DeviceSpaceOrCommunityController {
constructor(private readonly deviceService: DeviceService) {}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('DEVICE_VIEW')
@Get('recursive-child')
@ApiOperation({
summary:
ControllerRoute.DEVICE_SPACE_COMMUNITY.ACTIONS
.GET_ALL_DEVICES_BY_SPACE_OR_COMMUNITY_WITH_RECURSIVE_CHILD_SUMMARY,
description:
ControllerRoute.DEVICE_SPACE_COMMUNITY.ACTIONS
.GET_ALL_DEVICES_BY_SPACE_OR_COMMUNITY_WITH_RECURSIVE_CHILD_DESCRIPTION,
})
@ApiQuery({
name: 'spaceUuid',
description: 'UUID of the Space',
required: false,
})
@ApiQuery({
name: 'communityUuid',
description: 'UUID of the Community',
required: false,
})
async getAllDevicesBySpaceOrCommunityWithChild(
@Query() query: GetDevicesBySpaceOrCommunityDto,
) {
return await this.deviceService.getAllDevicesBySpaceOrCommunityWithChild(
query,
);
}
}

View File

@ -22,6 +22,8 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { DeviceProjectController } from './controllers/device-project.controller';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { DeviceSpaceOrCommunityController } from './controllers/device-space-community.controller';
@Module({
imports: [
ConfigModule,
@ -30,7 +32,11 @@ import { DeviceProjectController } from './controllers/device-project.controller
DeviceRepositoryModule,
DeviceStatusFirebaseModule,
],
controllers: [DeviceController, DeviceProjectController],
controllers: [
DeviceController,
DeviceProjectController,
DeviceSpaceOrCommunityController,
],
providers: [
DeviceService,
ProductRepository,
@ -46,6 +52,7 @@ import { DeviceProjectController } from './controllers/device-project.controller
SceneRepository,
SceneDeviceRepository,
AutomationRepository,
CommunityRepository,
],
exports: [DeviceService],
})

View File

@ -1,6 +1,13 @@
import { DeviceTypeEnum } from '@app/common/constants/device-type.enum';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export class GetDeviceBySpaceUuidDto {
@ApiProperty({
@ -44,3 +51,24 @@ export class GetDoorLockDevices {
@IsOptional()
public deviceType: DeviceTypeEnum;
}
export class GetDevicesBySpaceOrCommunityDto {
@ApiProperty({
description: 'Device Product Type',
example: 'PC',
required: true,
})
@IsString()
@IsNotEmpty()
public productType: string;
@IsUUID('4', { message: 'Invalid space UUID format' })
@IsOptional()
spaceUuid?: string;
@IsUUID('4', { message: 'Invalid community UUID format' })
@IsOptional()
communityUuid?: string;
@ValidateIf((o) => !o.spaceUuid && !o.communityUuid)
@IsNotEmpty({ message: 'Either spaceUuid or communityUuid must be provided' })
requireEither?: never; // This ensures at least one of them is provided
}

View File

@ -31,6 +31,7 @@ import {
import {
GetDeviceBySpaceUuidDto,
GetDeviceLogsDto,
GetDevicesBySpaceOrCommunityDto,
GetDoorLockDevices,
} from '../dtos/get.device.dto';
import {
@ -65,6 +66,7 @@ import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { ProjectParam } from '../dtos';
import { BatchDeviceTypeEnum } from '@app/common/constants/batch-device.enum';
import { DeviceTypeEnum } from '@app/common/constants/device-type.enum';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Injectable()
export class DeviceService {
@ -80,6 +82,7 @@ export class DeviceService {
private readonly sceneService: SceneService,
private readonly tuyaService: TuyaService,
private readonly projectRepository: ProjectRepository,
private readonly communityRepository: CommunityRepository,
) {
const accessKey = this.configService.get<string>('auth-config.ACCESS_KEY');
const secretKey = this.configService.get<string>('auth-config.SECRET_KEY');
@ -1722,4 +1725,137 @@ export class DeviceService {
statusCode: HttpStatus.OK,
});
}
async getAllDevicesBySpaceOrCommunityWithChild(
query: GetDevicesBySpaceOrCommunityDto,
): Promise<BaseResponseDto> {
try {
const { spaceUuid, communityUuid, productType } = query;
if (!spaceUuid && !communityUuid) {
throw new BadRequestException(
'Either spaceUuid or communityUuid must be provided',
);
}
// Get devices based on space or community
const devices = spaceUuid
? await this.getAllDevicesBySpace(spaceUuid)
: await this.getAllDevicesByCommunity(communityUuid);
if (!devices?.length) {
return new SuccessResponseDto({
message: `No devices found for ${spaceUuid ? 'space' : 'community'}`,
data: [],
statusCode: HttpStatus.CREATED,
});
}
const devicesFilterd = devices.filter(
(device) => device.productDevice?.prodType === productType,
);
if (devicesFilterd.length === 0) {
return new SuccessResponseDto({
message: `No ${productType} devices found for ${spaceUuid ? 'space' : 'community'}`,
data: [],
statusCode: HttpStatus.CREATED,
});
}
return new SuccessResponseDto({
message: `Devices fetched successfully`,
data: devicesFilterd,
statusCode: HttpStatus.OK,
});
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getAllDevicesBySpace(spaceUuid: string): Promise<DeviceEntity[]> {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: ['children', 'devices', 'devices.productDevice'],
});
if (!space) {
throw new NotFoundException('Space not found');
}
const allDevices: DeviceEntity[] = [...space.devices];
// Recursive fetch function
const fetchChildren = async (parentSpace: SpaceEntity) => {
const children = await this.spaceRepository.find({
where: { parent: { uuid: parentSpace.uuid } },
relations: ['children', 'devices', 'devices.productDevice'],
});
for (const child of children) {
allDevices.push(...child.devices);
if (child.children.length > 0) {
await fetchChildren(child);
}
}
};
// Start recursive fetch
await fetchChildren(space);
return allDevices;
}
async getAllDevicesByCommunity(
communityUuid: string,
): Promise<DeviceEntity[]> {
const community = await this.communityRepository.findOne({
where: { uuid: communityUuid },
relations: [
'spaces',
'spaces.children',
'spaces.devices',
'spaces.devices.productDevice',
],
});
if (!community) {
throw new NotFoundException('Community not found');
}
const allDevices: DeviceEntity[] = [];
const visitedSpaceUuids = new Set<string>();
// Recursive fetch function with visited check
const fetchSpaceDevices = async (space: SpaceEntity) => {
if (visitedSpaceUuids.has(space.uuid)) return;
visitedSpaceUuids.add(space.uuid);
if (space.devices?.length) {
allDevices.push(...space.devices);
}
if (space.children?.length) {
for (const child of space.children) {
const fullChild = await this.spaceRepository.findOne({
where: { uuid: child.uuid },
relations: ['children', 'devices', 'devices.productDevice'],
});
if (fullChild) {
await fetchSpaceDevices(fullChild);
}
}
}
};
for (const space of community.spaces) {
await fetchSpaceDevices(space);
}
return allDevices;
}
}

View File

@ -27,6 +27,8 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule],
controllers: [DoorLockController],
@ -52,6 +54,8 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
CommunityRepository,
],
exports: [DoorLockService],
})

View File

@ -25,6 +25,8 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule],
controllers: [GroupController],
@ -49,6 +51,8 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
CommunityRepository,
],
exports: [GroupService],
})

View File

@ -83,6 +83,7 @@ import {
} from '@app/common/modules/power-clamp/repositories';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Module({
imports: [ConfigModule, InviteUserRepositoryModule, CommunityModule],
@ -152,6 +153,7 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
exports: [InviteUserService],
})

View File

@ -0,0 +1 @@
export * from './occupancy.controller';

View File

@ -0,0 +1,71 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { OccupancyService } from '../services/occupancy.service';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
@ApiTags('Occupancy Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.Occupancy.ROUTE,
})
export class OccupancyController {
constructor(private readonly occupancyService: OccupancyService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('heat-map/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyHeatMapDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyHeatMapDataBySpace(
params,
query,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('duration/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyDurationDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyDurationDataBySpace(
params,
query,
);
}
}

View File

@ -0,0 +1,27 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Matches, IsNotEmpty } from 'class-validator';
export class GetOccupancyHeatMapBySpaceDto {
@ApiPropertyOptional({
description: 'Input year in YYYY format to filter the data',
example: '2025',
required: false,
})
@IsNotEmpty()
@Matches(/^\d{4}$/, {
message: 'Year must be in YYYY format',
})
year: string;
}
export class GetOccupancyDurationBySpaceDto {
@ApiPropertyOptional({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsNotEmpty()
spaceUuid: string;
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { OccupancyController } from './controllers';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from './services';
@Module({
imports: [ConfigModule],
controllers: [OccupancyController],
providers: [OccupancyService, SqlLoaderService],
})
export class OccupancyModule {}

View File

@ -0,0 +1 @@
export * from './occupancy.service';

View File

@ -0,0 +1,103 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@Injectable()
export class OccupancyService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
) {}
async getOccupancyDurationDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_occupancy_duration',
'procedure_select_daily_space_occupancy_duration',
[spaceUuid, monthDate],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy duration data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy duration data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy duration data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getOccupancyHeatMapDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
const { year } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_space_occupancy_count',
'proceduce_select_fact_space_occupancy',
[spaceUuid, year],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy heat map data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy heat map data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy heat map data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private buildResponse(message: string, data: any[]) {
return new SuccessResponseDto({
message,
data,
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -4,14 +4,21 @@ import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { PowerClampService } from '../services/power-clamp.service';
import { GetPowerClampDto } from '../dto/get-power-clamp.dto';
import {
GetPowerClampBySpaceDto,
GetPowerClampDto,
} from '../dto/get-power-clamp.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PowerClampParamsDto } from '../dto/power-clamp-params.dto';
import {
PowerClampParamsDto,
ResourceParamsDto,
} from '../dto/power-clamp-params.dto';
@ApiTags('Power Clamp Module')
@Controller({
@ -39,4 +46,34 @@ export class PowerClampController {
): Promise<BaseResponseDto> {
return await this.powerClampService.getPowerClampData(params, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('historical')
@ApiOperation({
summary:
ControllerRoute.PowerClamp.ACTIONS
.GET_ENERGY_BY_COMMUNITY_OR_SPACE_SUMMARY,
description:
ControllerRoute.PowerClamp.ACTIONS
.GET_ENERGY_BY_COMMUNITY_OR_SPACE_DESCRIPTION,
})
@ApiQuery({
name: 'spaceUuid',
description: 'UUID of the Space',
required: false,
})
@ApiQuery({
name: 'communityUuid',
description: 'UUID of the Community',
required: false,
})
async getPowerClampDataBySpaceOrCommunity(
@Query() params: ResourceParamsDto,
@Query() query: GetPowerClampBySpaceDto,
): Promise<BaseResponseDto> {
return await this.powerClampService.getPowerClampDataBySpaceOrCommunity(
params,
query,
);
}
}

View File

@ -1,5 +1,13 @@
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsDateString, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
import {
IsOptional,
IsDateString,
Matches,
IsNotEmpty,
IsBoolean,
} from 'class-validator';
export class GetPowerClampDto {
@ApiPropertyOptional({
@ -33,3 +41,26 @@ export class GetPowerClampDto {
})
year?: string;
}
export class GetPowerClampBySpaceDto {
@ApiPropertyOptional({
description: 'monthDate must be in YYYY-MM format',
example: '2025-04',
required: true,
})
@IsDateString()
@IsNotEmpty()
public monthDate: string;
@ApiPropertyOptional({
example: true,
description: 'Whether to group results by device or not',
required: false,
default: false,
})
@IsOptional()
@IsBoolean()
@Transform((value) => {
return value.obj.groupByDevice === BooleanValues.TRUE;
})
public groupByDevice?: boolean = false;
}

View File

@ -1,6 +1,23 @@
import { IsUUID } from 'class-validator';
import { IsNotEmpty, IsOptional, IsUUID, ValidateIf } from 'class-validator';
export class PowerClampParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
powerClampUuid: string;
}
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
spaceUuid: string;
}
export class ResourceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsOptional()
spaceUuid?: string;
@IsUUID('4', { message: 'Invalid UUID format' })
@IsOptional()
communityUuid?: string;
@ValidateIf((o) => !o.spaceUuid && !o.communityUuid)
@IsNotEmpty({ message: 'Either spaceUuid or communityUuid must be provided' })
requireEither?: never; // This ensures at least one of them is provided
}

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PowerClampService } from './services/power-clamp.service';
import { PowerClampService as PowerClamp } from './services/power-clamp.service';
import { PowerClampController } from './controllers';
import {
PowerClampDailyRepository,
@ -8,16 +8,110 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import {
SpaceDeviceService,
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
ValidationService,
} from 'src/space/services';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { DeviceService } from 'src/device/services';
import {
InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository,
SpaceRepository,
TagRepository,
} from '@app/common/modules/space';
import { CommunityService } from 'src/community/services';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import {
SpaceModelProductAllocationRepoitory,
SpaceModelRepository,
SubspaceModelProductAllocationRepoitory,
SubspaceModelRepository,
} from '@app/common/modules/space-model';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { ProductRepository } from '@app/common/modules/product/repositories';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { SceneService } from 'src/scene/services';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
import {
SceneIconRepository,
SceneRepository,
} from '@app/common/modules/scene/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { TagService } from 'src/tags/services';
import {
SpaceModelService,
SubSpaceModelService,
} from 'src/space-model/services';
import { SpaceProductAllocationService } from 'src/space/services/space-product-allocation.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import {
SubspaceProductAllocationRepository,
SubspaceRepository,
} from '@app/common/modules/space/repositories/subspace.repository';
import { SubspaceProductAllocationService } from 'src/space/services/subspace/subspace-product-allocation.service';
import { NewTagRepository } from '@app/common/modules/tag/repositories/tag-repository';
import { SpaceModelProductAllocationService } from 'src/space-model/services/space-model-product-allocation.service';
import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
@Module({
imports: [ConfigModule],
controllers: [PowerClampController],
providers: [
PowerClampService,
PowerClamp,
PowerClampDailyRepository,
PowerClampHourlyRepository,
PowerClampMonthlyRepository,
DeviceRepository,
SpaceDeviceService,
TuyaService,
ValidationService,
DeviceService,
SpaceRepository,
CommunityService,
ProjectRepository,
CommunityRepository,
SpaceModelRepository,
SceneDeviceRepository,
ProductRepository,
DeviceStatusFirebaseService,
SceneService,
SpaceService,
PowerClampService,
DeviceStatusLogRepository,
SceneIconRepository,
SceneRepository,
AutomationRepository,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
TagService,
SpaceModelService,
SpaceProductAllocationService,
SqlLoaderService,
SpaceLinkRepository,
SubspaceRepository,
SubspaceDeviceService,
SubspaceProductAllocationService,
NewTagRepository,
SubSpaceModelService,
SpaceModelProductAllocationService,
SpaceProductAllocationRepository,
SubspaceProductAllocationRepository,
TagRepository,
SubspaceModelRepository,
SubspaceModelProductAllocationService,
SpaceModelProductAllocationRepoitory,
SubspaceModelProductAllocationRepoitory,
OccupancyService,
],
exports: [PowerClampService],
exports: [PowerClamp],
})
export class PowerClampModule {}

View File

@ -1,13 +1,32 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { GetPowerClampDto } from '../dto/get-power-clamp.dto';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import {
GetPowerClampBySpaceDto,
GetPowerClampDto,
} from '../dto/get-power-clamp.dto';
import {
PowerClampDailyRepository,
PowerClampHourlyRepository,
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { PowerClampParamsDto } from '../dto/power-clamp-params.dto';
import {
PowerClampParamsDto,
ResourceParamsDto,
} from '../dto/power-clamp-params.dto';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { SpaceDeviceService } from 'src/space/services';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { filterByMonth, toMMYYYY } from '@app/common/helper/date-format';
import { ProductType } from '@app/common/constants/product-type.enum';
import { CommunityService } from 'src/community/services';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@Injectable()
export class PowerClampService {
@ -16,8 +35,119 @@ export class PowerClampService {
private readonly powerClampHourlyRepository: PowerClampHourlyRepository,
private readonly powerClampMonthlyRepository: PowerClampMonthlyRepository,
private readonly deviceRepository: DeviceRepository,
private readonly spaceDeviceService: SpaceDeviceService,
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
private readonly communityService: CommunityService,
) {}
async getPowerClampDataBySpaceOrCommunity(
params: ResourceParamsDto,
query: GetPowerClampBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate, groupByDevice } = query;
const { spaceUuid, communityUuid } = params;
try {
// Validate we have at least one identifier
if (!spaceUuid && !communityUuid) {
throw new BadRequestException(
'Either spaceUuid or communityUuid must be provided',
);
}
// Get devices based on space or community
const devices = spaceUuid
? await this.spaceDeviceService.getAllDevicesBySpace(spaceUuid)
: await this.communityService.getAllDevicesByCommunity(communityUuid);
if (!devices?.length) {
return this.buildResponse(
`No devices found for ${spaceUuid ? 'space' : 'community'}`,
[],
);
}
// Filter power clamp devices
const powerClampDevices = devices.filter(
(device) => device.productDevice?.prodType === ProductType.PC,
);
if (powerClampDevices.length === 0) {
return this.buildResponse(
`No power clamp devices found for ${spaceUuid ? 'space' : 'community'}`,
[],
);
}
const formattedMonthDate = toMMYYYY(monthDate);
if (groupByDevice) {
// Handle per-device response
const deviceDataPromises = powerClampDevices.map(async (device) => {
const data = await this.executeProcedure(
'fact_daily_space_energy_consumed_procedure',
[formattedMonthDate, device.uuid],
);
const formattedData = data.map((item) => ({
...item,
date: new Date(item.date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
const filteredData = monthDate
? filterByMonth(formattedData, monthDate)
: formattedData;
return {
deviceUuid: device.uuid,
deviceName: device.name || `Power Clamp Device`,
data: filteredData,
};
});
const deviceDataResults = await Promise.all(deviceDataPromises);
return this.buildResponse(
`Power clamp data fetched successfully for ${spaceUuid ? 'space' : 'community'}`,
deviceDataResults,
);
} else {
// Original behavior - all devices together
const deviceUuids = powerClampDevices.map((device) => device.uuid);
const data = await this.executeProcedure(
'fact_daily_space_energy_consumed_procedure',
[formattedMonthDate, deviceUuids.join(',')],
);
// Format and filter data
const formattedData = data.map((item) => ({
...item,
date: new Date(item.date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
const resultData = monthDate
? filterByMonth(formattedData, monthDate)
: formattedData;
return this.buildResponse(
`Power clamp data fetched successfully for ${spaceUuid ? 'space' : 'community'}`,
resultData,
);
}
} catch (error) {
console.error('Error fetching power clamp data', {
error,
spaceUuid,
communityUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch power clamp data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getPowerClampData(
params: PowerClampParamsDto,
query: GetPowerClampDto,
@ -99,4 +229,17 @@ export class PowerClampService {
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(
'fact_space_energy_consumed',
procedureFileName,
);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -1,4 +1,6 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import {
Body,
Controller,
@ -13,7 +15,6 @@ import {
Res,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import {
ApiBearerAuth,
ApiOperation,
@ -21,11 +22,10 @@ import {
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ProjectService } from '../services';
import { Response } from 'express';
import { CreateProjectDto, GetProjectParam } from '../dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto';
import { ListProjectsDto } from '../dto/list-project.dto';
import { ProjectService } from '../services';
@ApiTags('Project Module')
@Controller({
@ -80,9 +80,7 @@ export class ProjectController {
description: ControllerRoute.PROJECT.ACTIONS.LIST_PROJECTS_DESCRIPTION,
})
@Get()
async list(
@Query() query: PaginationRequestGetListDto,
): Promise<BaseResponseDto> {
async list(@Query() query: ListProjectsDto): Promise<BaseResponseDto> {
return this.projectService.listProjects(query);
}

View File

@ -0,0 +1,7 @@
import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto';
import { PickType } from '@nestjs/swagger';
export class ListProjectsDto extends PickType(PaginationRequestGetListDto, [
'page',
'size',
]) {}

View File

@ -67,6 +67,7 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
const CommandHandlers = [CreateOrphanSpaceHandler];
@ -124,6 +125,7 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
exports: [ProjectService, CqrsModule],
})

View File

@ -16,6 +16,7 @@ import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Module({
imports: [ConfigModule, SpaceRepositoryModule, DeviceStatusFirebaseModule],
@ -32,6 +33,7 @@ import { ProjectRepository } from '@app/common/modules/project/repositiories';
ProjectRepository,
SceneDeviceRepository,
AutomationRepository,
CommunityRepository,
],
exports: [SceneService],
})

View File

@ -65,6 +65,7 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
const CommandHandlers = [
PropogateUpdateSpaceModelHandler,
@ -124,6 +125,7 @@ const CommandHandlers = [
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
],
exports: [CqrsModule, SpaceModelService],
})

View File

@ -1,11 +1,12 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { GetSpaceParam } from '../dtos';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceDeviceService } from '../services';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
import { GetDevicesBySpaceDto } from '../dtos/device.space.dto';
@ApiTags('Space Module')
@Controller({
@ -26,7 +27,8 @@ export class SpaceDeviceController {
@Get()
async listDevicesInSpace(
@Param() params: GetSpaceParam,
@Query() query: GetDevicesBySpaceDto,
): Promise<BaseResponseDto> {
return await this.spaceDeviceService.listDevicesInSpace(params);
return await this.spaceDeviceService.listDevicesInSpace(params, query);
}
}

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class GetDevicesBySpaceDto {
@ApiProperty({
description: 'Device Product Type',
example: 'PC',
required: false,
})
@IsString()
@IsOptional()
public productType?: string;
}

View File

@ -1,6 +1,11 @@
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { GetSpaceParam } from '../dtos';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
@ -9,6 +14,10 @@ import { ValidationService } from './space-validation.service';
import { ProductType } from '@app/common/constants/product-type.enum';
import { BatteryStatus } from '@app/common/constants/battery-status.enum';
import { DeviceService } from 'src/device/services';
import { SpaceRepository } from '@app/common/modules/space';
import { DeviceEntity } from '@app/common/modules/device/entities';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { GetDevicesBySpaceDto } from '../dtos/device.space.dto';
@Injectable()
export class SpaceDeviceService {
@ -16,11 +25,15 @@ export class SpaceDeviceService {
private readonly tuyaService: TuyaService,
private readonly validationService: ValidationService,
private readonly deviceService: DeviceService,
private readonly spaceRepository: SpaceRepository,
) {}
async listDevicesInSpace(params: GetSpaceParam): Promise<BaseResponseDto> {
async listDevicesInSpace(
params: GetSpaceParam,
query: GetDevicesBySpaceDto,
): Promise<BaseResponseDto> {
const { spaceUuid, communityUuid, projectUuid } = params;
const { productType } = query;
try {
// Validate community, project, and fetch space including devices in a single query
const space = await this.validationService.fetchSpaceDevices(spaceUuid);
@ -42,7 +55,23 @@ export class SpaceDeviceService {
const detailedDevices = (await Promise.allSettled(deviceDetailsPromises))
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => (result as PromiseFulfilledResult<any>).value);
console.log('detailedDevices', detailedDevices);
if (productType) {
const devicesFilterd = detailedDevices.filter(
(device) => device.productType === productType,
);
if (devicesFilterd.length === 0) {
return new SuccessResponseDto({
message: `No ${productType} devices found for ${spaceUuid ? 'space' : 'community'}`,
data: [],
statusCode: HttpStatus.CREATED,
});
}
return new SuccessResponseDto({
data: devicesFilterd,
message: 'Successfully retrieved list of devices.',
});
}
return new SuccessResponseDto({
data: detailedDevices,
message: 'Successfully retrieved list of devices.',
@ -121,4 +150,37 @@ export class SpaceDeviceService {
);
return batteryStatus ? batteryStatus.value : null;
}
async getAllDevicesBySpace(spaceUuid: string): Promise<DeviceEntity[]> {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: ['children', 'devices', 'devices.productDevice'],
});
if (!space) {
throw new NotFoundException('Space not found');
}
const allDevices: DeviceEntity[] = [...space.devices];
// Recursive fetch function
const fetchChildren = async (parentSpace: SpaceEntity) => {
const children = await this.spaceRepository.find({
where: { parent: { uuid: parentSpace.uuid } },
relations: ['children', 'devices', 'devices.productDevice'],
});
for (const child of children) {
allDevices.push(...child.devices);
if (child.children.length > 0) {
await fetchChildren(child);
}
}
};
// Start recursive fetch
await fetchChildren(space);
return allDevices;
}
}

View File

@ -90,6 +90,7 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
export const CommandHandlers = [DisableSpaceHandler];
@ -166,6 +167,7 @@ export const CommandHandlers = [DisableSpaceHandler];
PowerClampMonthlyRepository,
PowerClampService,
SqlLoaderService,
OccupancyService,
],
exports: [SpaceService],
})

View File

@ -29,6 +29,8 @@ import {
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule],
controllers: [VisitorPasswordController],
@ -55,6 +57,8 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
CommunityRepository,
],
exports: [VisitorPasswordService],
})