diff --git a/database.types.ts b/database.types.ts index bb38207..7eff05a 100644 --- a/database.types.ts +++ b/database.types.ts @@ -649,6 +649,275 @@ export type Database = { }, ] } + cw_device_report_assignments: { + Row: { + created_at: string + dev_eui: string + id: number + is_active: boolean + template_id: number + } + Insert: { + created_at?: string + dev_eui: string + id?: number + is_active?: boolean + template_id: number + } + Update: { + created_at?: string + dev_eui?: string + id?: number + is_active?: boolean + template_id?: number + } + Relationships: [ + { + foreignKeyName: "cw_device_report_assignments_dev_eui_fkey" + columns: ["dev_eui"] + isOneToOne: false + referencedRelation: "cw_devices" + referencedColumns: ["dev_eui"] + }, + { + foreignKeyName: "cw_device_report_assignments_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "cw_report_templates" + referencedColumns: ["id"] + }, + ] + } + cw_report_template_alert_points: { + Row: { + created_at: string + data_point_key: string + hex_color: string | null + id: number + max: number | null + min: number | null + name: string + operator: string | null + template_id: number + value: number | null + } + Insert: { + created_at?: string + data_point_key: string + hex_color?: string | null + id?: number + max?: number | null + min?: number | null + name: string + operator?: string | null + template_id: number + value?: number | null + } + Update: { + created_at?: string + data_point_key?: string + hex_color?: string | null + id?: number + max?: number | null + min?: number | null + name?: string + operator?: string | null + template_id?: number + value?: number | null + } + Relationships: [ + { + foreignKeyName: "cw_report_template_alert_points_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "cw_report_templates" + referencedColumns: ["id"] + }, + ] + } + cw_report_template_data_processing_schedules: { + Row: { + created_at: string + crosses_midnight: boolean + day_of_week: number + end_time: string + id: string + is_enabled: boolean + rule_type: string + start_time: string + template_id: number + timezone: string + updated_at: string + valid_from: string | null + valid_to: string | null + } + Insert: { + created_at?: string + crosses_midnight?: boolean + day_of_week: number + end_time: string + id?: string + is_enabled?: boolean + rule_type?: string + start_time: string + template_id: number + timezone?: string + updated_at?: string + valid_from?: string | null + valid_to?: string | null + } + Update: { + created_at?: string + crosses_midnight?: boolean + day_of_week?: number + end_time?: string + id?: string + is_enabled?: boolean + rule_type?: string + start_time?: string + template_id?: number + timezone?: string + updated_at?: string + valid_from?: string | null + valid_to?: string | null + } + Relationships: [ + { + foreignKeyName: "cw_report_template_data_processing_sche_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "cw_report_templates" + referencedColumns: ["id"] + }, + ] + } + cw_report_template_recipients: { + Row: { + communication_method: number + created_at: string + email: string | null + id: number + name: string | null + template_id: number + } + Insert: { + communication_method: number + created_at?: string + email?: string | null + id?: number + name?: string | null + template_id: number + } + Update: { + communication_method?: number + created_at?: string + email?: string | null + id?: number + name?: string | null + template_id?: number + } + Relationships: [ + { + foreignKeyName: "cw_report_template_recipients_communicat_communication_meth_fkey" + columns: ["communication_method"] + isOneToOne: false + referencedRelation: "communication_methods" + referencedColumns: ["communication_method_id"] + }, + { + foreignKeyName: "cw_report_template_recipients_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "cw_report_templates" + referencedColumns: ["id"] + }, + ] + } + cw_report_template_schedule: { + Row: { + created_at: string + end_of_day: boolean + end_of_month: boolean + end_of_week: boolean + id: number + is_active: boolean + template_id: number + utc_offset: number + } + Insert: { + created_at?: string + end_of_day?: boolean + end_of_month?: boolean + end_of_week?: boolean + id?: number + is_active?: boolean + template_id: number + utc_offset?: number + } + Update: { + created_at?: string + end_of_day?: boolean + end_of_month?: boolean + end_of_week?: boolean + id?: number + is_active?: boolean + template_id?: number + utc_offset?: number + } + Relationships: [ + { + foreignKeyName: "cw_report_template_schedule_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "cw_report_templates" + referencedColumns: ["id"] + }, + ] + } + cw_report_templates: { + Row: { + created_at: string + created_by: string | null + data_pull_interval: number + description: string | null + device_type_id: number | null + id: number + is_active: boolean + legacy_report_id: string | null + name: string + } + Insert: { + created_at?: string + created_by?: string | null + data_pull_interval?: number + description?: string | null + device_type_id?: number | null + id?: number + is_active?: boolean + legacy_report_id?: string | null + name: string + } + Update: { + created_at?: string + created_by?: string | null + data_pull_interval?: number + description?: string | null + device_type_id?: number | null + id?: number + is_active?: boolean + legacy_report_id?: string | null + name?: string + } + Relationships: [ + { + foreignKeyName: "cw_report_templates_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } cw_device_rule_assignments: { Row: { created_at: string | null diff --git a/src/app.module.ts b/src/app.module.ts index 051c74a..571ee64 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { DevicesModule } from './v1/devices/devices.module'; import { RulesModule } from './v1/rules/rules.module'; import { RulesNewModule } from './v1/rules-new/rules-new.module'; import { ReportsModule } from './v1/reports/reports.module'; +import { ReportsNewModule } from './v1/reports-new/reports-new.module'; import { PaymentsModule } from './v1/payments/payments.module'; import { LocationsModule } from './v1/locations/locations.module'; import { RelayModule } from './v1/relay/relay.module'; @@ -56,6 +57,7 @@ import { DashboardModule } from './v1/dashboard/dashboard.module'; RulesModule, RulesNewModule, ReportsModule, + ReportsNewModule, PaymentsModule, LocationsModule, RelayModule, diff --git a/src/v1/dashboard/dashboard.service.ts b/src/v1/dashboard/dashboard.service.ts index e30f2ab..93a3c6c 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -58,7 +58,7 @@ export class DashboardService { let devicesQuery = client .from('cw_devices') .select( - `dev_eui, name, "group", upload_interval, last_data_updated_at, + `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, cw_device_type(id, name, data_table_v2, primary_data_v2, secondary_data_v2, default_upload_interval), ${locationSelect}, owner_match:cw_device_owners()`, @@ -211,7 +211,7 @@ export class DashboardService { let devicesQuery = client .from('cw_devices') .select( - `dev_eui, name, "group", upload_interval, last_data_updated_at, + `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, cw_device_type(id, name, data_table_v2, primary_data_v2, secondary_data_v2, default_upload_interval), cw_locations(location_id, name, "group"), owner_match:cw_device_owners()`, @@ -366,6 +366,7 @@ export class DashboardService { group: d.group ?? null, upload_interval: d.upload_interval ?? null, last_data_updated_at: d.last_data_updated_at ?? null, + error_status: d.error_status ?? null, device_type: { id: deviceType.id, name: deviceType.name, diff --git a/src/v1/dashboard/dashboard.types.ts b/src/v1/dashboard/dashboard.types.ts index 8bffccb..5777df8 100644 --- a/src/v1/dashboard/dashboard.types.ts +++ b/src/v1/dashboard/dashboard.types.ts @@ -13,6 +13,8 @@ export interface DashboardRow { group: string | null; upload_interval: number | null; last_data_updated_at: string | null; + /** Device-reported fault string; non-empty means the sensor needs attention. */ + error_status: string | null; device_type: { id: number; diff --git a/src/v1/reports-new/dto/communication-method.dto.ts b/src/v1/reports-new/dto/communication-method.dto.ts new file mode 100644 index 0000000..63ae8a5 --- /dev/null +++ b/src/v1/reports-new/dto/communication-method.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CommunicationMethodDto { + @ApiProperty({ + description: + 'Stable identifier referenced by cw_report_template_recipients.communication_method.', + }) + communicationMethodId: number; + + @ApiProperty({ nullable: true, required: false }) + name: string | null; + + @ApiProperty() + isActive: boolean; +} diff --git a/src/v1/reports-new/dto/report-form-context.dto.ts b/src/v1/reports-new/dto/report-form-context.dto.ts new file mode 100644 index 0000000..c7f9d86 --- /dev/null +++ b/src/v1/reports-new/dto/report-form-context.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DeviceDto } from '../../devices/dto/device.dto'; +import { LocationDto } from '../../locations/dto/location.dto'; +import { CommunicationMethodDto } from './communication-method.dto'; +import { ReportTemplateDto } from './report-template.dto'; + +export class ReportFormContextDto { + @ApiProperty({ type: () => DeviceDto, isArray: true }) + devices: DeviceDto[]; + + @ApiProperty({ type: () => LocationDto, isArray: true }) + locations: LocationDto[]; + + @ApiProperty({ type: () => CommunicationMethodDto, isArray: true }) + communicationMethods: CommunicationMethodDto[]; + + @ApiProperty({ type: () => ReportTemplateDto, nullable: true, required: false }) + template: ReportTemplateDto | null; +} diff --git a/src/v1/reports-new/dto/report-template-alert-point.dto.ts b/src/v1/reports-new/dto/report-template-alert-point.dto.ts new file mode 100644 index 0000000..061ce6b --- /dev/null +++ b/src/v1/reports-new/dto/report-template-alert-point.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportTemplateAlertPointDto { + @ApiProperty() + id: number; + + @ApiProperty() + templateId: number; + + @ApiProperty() + name: string; + + @ApiProperty() + dataPointKey: string; + + @ApiProperty({ nullable: true, required: false }) + operator: string | null; + + @ApiProperty({ nullable: true, required: false }) + min: number | null; + + @ApiProperty({ nullable: true, required: false }) + max: number | null; + + @ApiProperty({ nullable: true, required: false }) + value: number | null; + + @ApiProperty({ nullable: true, required: false }) + hexColor: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; +} diff --git a/src/v1/reports-new/dto/report-template-assignment.dto.ts b/src/v1/reports-new/dto/report-template-assignment.dto.ts new file mode 100644 index 0000000..12fb3ae --- /dev/null +++ b/src/v1/reports-new/dto/report-template-assignment.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportTemplateAssignmentDto { + @ApiProperty() + id: number; + + @ApiProperty() + devEui: string; + + @ApiProperty() + templateId: number; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; + + @ApiProperty({ nullable: true, required: false }) + deviceName: string | null; + + @ApiProperty({ nullable: true, required: false }) + locationName: string | null; + + @ApiProperty({ nullable: true, required: false }) + permissionLevel: number | null; +} diff --git a/src/v1/reports-new/dto/report-template-data-processing-schedule.dto.ts b/src/v1/reports-new/dto/report-template-data-processing-schedule.dto.ts new file mode 100644 index 0000000..60632c4 --- /dev/null +++ b/src/v1/reports-new/dto/report-template-data-processing-schedule.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportTemplateDataProcessingScheduleDto { + @ApiProperty() + id: string; + + @ApiProperty() + templateId: number; + + @ApiProperty({ description: 'Day of week, 0 (Sunday) – 6 (Saturday).' }) + dayOfWeek: number; + + @ApiProperty({ description: 'HH:MM(:SS) local time.' }) + startTime: string; + + @ApiProperty({ description: 'HH:MM(:SS) local time.' }) + endTime: string; + + @ApiProperty() + crossesMidnight: boolean; + + @ApiProperty({ description: "'include' or 'exclude'." }) + ruleType: string; + + @ApiProperty({ nullable: true, required: false, format: 'date' }) + validFrom: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date' }) + validTo: string | null; + + @ApiProperty() + timezone: string; + + @ApiProperty() + isEnabled: boolean; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + updatedAt: string | null; +} diff --git a/src/v1/reports-new/dto/report-template-history-item.dto.ts b/src/v1/reports-new/dto/report-template-history-item.dto.ts new file mode 100644 index 0000000..e13bb34 --- /dev/null +++ b/src/v1/reports-new/dto/report-template-history-item.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * A generated report PDF in the `Reports` storage bucket, tagged with the + * device it belongs to. A report template can span many devices, so history is + * aggregated across the template's assigned devices. + */ +export class ReportTemplateHistoryItemDto { + @ApiProperty() + devEui: string; + + @ApiProperty({ nullable: true, required: false }) + deviceName: string | null; + + @ApiProperty({ description: 'Storage object name (the PDF file name).' }) + name: string; + + @ApiProperty({ nullable: true, required: false }) + id: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + updatedAt: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + lastAccessedAt: string | null; + + @ApiProperty({ nullable: true, required: false }) + metadata: Record | null; +} diff --git a/src/v1/reports-new/dto/report-template-recipient.dto.ts b/src/v1/reports-new/dto/report-template-recipient.dto.ts new file mode 100644 index 0000000..6892793 --- /dev/null +++ b/src/v1/reports-new/dto/report-template-recipient.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportTemplateRecipientDto { + @ApiProperty() + id: number; + + @ApiProperty() + templateId: number; + + @ApiProperty({ + description: + 'Foreign key to communication_methods.communication_method_id (1=email, 2=SMS, 3=Discord).', + }) + communicationMethod: number; + + @ApiProperty({ nullable: true, required: false }) + email: string | null; + + @ApiProperty({ nullable: true, required: false }) + name: string | null; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; +} diff --git a/src/v1/reports-new/dto/report-template-schedule.dto.ts b/src/v1/reports-new/dto/report-template-schedule.dto.ts new file mode 100644 index 0000000..a33abb1 --- /dev/null +++ b/src/v1/reports-new/dto/report-template-schedule.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportTemplateScheduleDto { + @ApiProperty() + id: number; + + @ApiProperty() + templateId: number; + + @ApiProperty() + endOfDay: boolean; + + @ApiProperty() + endOfWeek: boolean; + + @ApiProperty() + endOfMonth: boolean; + + @ApiProperty() + utcOffset: number; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; +} diff --git a/src/v1/reports-new/dto/report-template.dto.ts b/src/v1/reports-new/dto/report-template.dto.ts new file mode 100644 index 0000000..a27369f --- /dev/null +++ b/src/v1/reports-new/dto/report-template.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ReportTemplateAlertPointDto } from './report-template-alert-point.dto'; +import { ReportTemplateAssignmentDto } from './report-template-assignment.dto'; +import { ReportTemplateDataProcessingScheduleDto } from './report-template-data-processing-schedule.dto'; +import { ReportTemplateRecipientDto } from './report-template-recipient.dto'; +import { ReportTemplateScheduleDto } from './report-template-schedule.dto'; + +export class ReportTemplateDto { + @ApiProperty() + id: number; + + @ApiProperty() + name: string; + + @ApiProperty({ nullable: true, required: false }) + description: string | null; + + @ApiProperty({ nullable: true, required: false }) + deviceTypeId: number | null; + + @ApiProperty({ description: 'Sampling interval in minutes used when building the report.' }) + dataPullInterval: number; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + createdAt: string | null; + + @ApiProperty({ type: () => ReportTemplateAssignmentDto, isArray: true }) + assignments: ReportTemplateAssignmentDto[]; + + @ApiProperty({ type: () => ReportTemplateScheduleDto, isArray: true }) + schedule: ReportTemplateScheduleDto[]; + + @ApiProperty({ type: () => ReportTemplateRecipientDto, isArray: true }) + recipients: ReportTemplateRecipientDto[]; + + @ApiProperty({ type: () => ReportTemplateAlertPointDto, isArray: true }) + alertPoints: ReportTemplateAlertPointDto[]; + + @ApiProperty({ type: () => ReportTemplateDataProcessingScheduleDto, isArray: true }) + dataProcessingSchedules: ReportTemplateDataProcessingScheduleDto[]; +} diff --git a/src/v1/reports-new/dto/save-report-template.dto.ts b/src/v1/reports-new/dto/save-report-template.dto.ts new file mode 100644 index 0000000..cdca286 --- /dev/null +++ b/src/v1/reports-new/dto/save-report-template.dto.ts @@ -0,0 +1,209 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; + +export class SaveReportTemplateScheduleDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + endOfDay?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + endOfWeek?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + endOfMonth?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + utcOffset?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class SaveReportTemplateRecipientDto { + @ApiProperty({ + description: + 'Foreign key to communication_methods.communication_method_id.', + }) + @IsInt() + communicationMethod: number; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + email?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + name?: string | null; +} + +export class SaveReportTemplateAlertPointDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + dataPointKey: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + operator?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsNumber() + min?: number | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsNumber() + max?: number | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsNumber() + value?: number | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + hexColor?: string | null; +} + +export class SaveReportTemplateDataProcessingScheduleDto { + @ApiProperty({ description: 'Day of week, 0 (Sunday) – 6 (Saturday).' }) + @IsInt() + dayOfWeek: number; + + @ApiProperty({ description: 'HH:MM(:SS) local time.' }) + @IsString() + @IsNotEmpty() + startTime: string; + + @ApiProperty({ description: 'HH:MM(:SS) local time.' }) + @IsString() + @IsNotEmpty() + endTime: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + crossesMidnight?: boolean; + + @ApiProperty({ required: false, description: "'include' or 'exclude'." }) + @IsOptional() + @IsString() + ruleType?: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + validFrom?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + validTo?: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + timezone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} + +export class SaveReportTemplateDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + description?: string | null; + + @ApiProperty({ required: false, minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + dataPullInterval?: number; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsInt() + deviceTypeId?: number | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiProperty({ type: String, isArray: true }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + devEuis: string[]; + + @ApiProperty({ type: () => SaveReportTemplateScheduleDto, isArray: true, required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveReportTemplateScheduleDto) + schedule?: SaveReportTemplateScheduleDto[]; + + @ApiProperty({ type: () => SaveReportTemplateRecipientDto, isArray: true, required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveReportTemplateRecipientDto) + recipients?: SaveReportTemplateRecipientDto[]; + + @ApiProperty({ type: () => SaveReportTemplateAlertPointDto, isArray: true, required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveReportTemplateAlertPointDto) + alertPoints?: SaveReportTemplateAlertPointDto[]; + + @ApiProperty({ + type: () => SaveReportTemplateDataProcessingScheduleDto, + isArray: true, + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveReportTemplateDataProcessingScheduleDto) + dataProcessingSchedules?: SaveReportTemplateDataProcessingScheduleDto[]; +} diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports-new/reports-new.controller.ts new file mode 100644 index 0000000..3279f0d --- /dev/null +++ b/src/v1/reports-new/reports-new.controller.ts @@ -0,0 +1,177 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiQuery, + ApiSecurity, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { CommunicationMethodDto } from './dto/communication-method.dto'; +import { ReportFormContextDto } from './dto/report-form-context.dto'; +import { ReportTemplateDto } from './dto/report-template.dto'; +import { ReportTemplateHistoryItemDto } from './dto/report-template-history-item.dto'; +import { SaveReportTemplateDto } from './dto/save-report-template.dto'; +import { ReportsNewService } from './reports-new.service'; + +@ApiBearerAuth('bearerAuth') +@ApiSecurity('apiKey') +@Controller({ path: 'reports-new', version: '1' }) +export class ReportsNewController { + constructor(private readonly reportsNewService: ReportsNewService) {} + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'Lists every report template visible to the current user.', + type: ReportTemplateDto, + isArray: true, + }) + @ApiQuery({ + name: 'search', + description: 'Filter templates by name, description, or assigned device.', + required: false, + }) + @Get() + findAll(@Req() req, @Query('search') search?: string) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.findAll(req.user, authHeader, search); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + 'Lists every communication method a report template recipient can use.', + type: CommunicationMethodDto, + isArray: true, + }) + @Get('communication-methods') + findAllCommunicationMethods(@Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.findAllCommunicationMethods(authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + 'Bundled data needed to render the reports-new create/edit form: devices (with cw_locations join), locations, communication methods, and optionally a template.', + type: ReportFormContextDto, + }) + @ApiQuery({ + name: 'templateId', + description: 'When provided, the matching report template is included in the response.', + required: false, + type: Number, + }) + @Get('form-context') + getFormContext(@Req() req, @Query('templateId') templateId?: string) { + const authHeader = req.headers?.authorization ?? ''; + const parsed = templateId !== undefined ? Number(templateId) : NaN; + const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + return this.reportsNewService.getFormContext(req.user, authHeader, id); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + 'Returns a signed URL to download a generated report PDF for a device the user can view.', + schema: { + type: 'object', + properties: { url: { type: 'string' } }, + }, + }) + @Get('download/:dev_eui/:reportName') + download( + @Param('dev_eui') devEui: string, + @Param('reportName') reportName: string, + @Req() req, + ) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.getDownloadUrl( + devEui, + reportName, + req.user, + authHeader, + ); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Lists generated report PDFs across the template's assigned devices, newest first.", + type: ReportTemplateHistoryItemDto, + isArray: true, + }) + @Get(':id/history') + findHistory(@Param('id', ParseIntPipe) id: number, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.getHistory(id, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'Returns a single report template the user can view.', + type: ReportTemplateDto, + isArray: false, + }) + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.findOne(id, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'Creates a report template and assigns it to the listed devices.', + type: ReportTemplateDto, + isArray: false, + }) + @ApiBody({ type: SaveReportTemplateDto }) + @Post() + create(@Body() body: SaveReportTemplateDto, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.create(body, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'Replaces a report template with the provided configuration.', + type: ReportTemplateDto, + isArray: false, + }) + @ApiBody({ type: SaveReportTemplateDto }) + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() body: SaveReportTemplateDto, + @Req() req, + ) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.update(id, body, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: 'Deletes a report template the user manages.', + schema: { + type: 'object', + properties: { id: { type: 'number' } }, + }, + }) + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.reportsNewService.remove(id, req.user, authHeader); + } +} diff --git a/src/v1/reports-new/reports-new.module.ts b/src/v1/reports-new/reports-new.module.ts new file mode 100644 index 0000000..5294cc5 --- /dev/null +++ b/src/v1/reports-new/reports-new.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SupabaseModule } from '../../supabase/supabase.module'; +import { DevicesModule } from '../devices/devices.module'; +import { LocationsModule } from '../locations/locations.module'; +import { ReportsNewController } from './reports-new.controller'; +import { ReportsNewService } from './reports-new.service'; + +@Module({ + imports: [SupabaseModule, DevicesModule, LocationsModule], + controllers: [ReportsNewController], + providers: [ReportsNewService], +}) +export class ReportsNewModule {} diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports-new/reports-new.service.ts new file mode 100644 index 0000000..f1f1a1d --- /dev/null +++ b/src/v1/reports-new/reports-new.service.ts @@ -0,0 +1,1132 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { + getAccessToken, + getUserId, + isCropwatchStaff, +} from '../../supabase/supabase-token.helper'; +import type { TableRow } from '../types/supabase'; +import { DevicesService } from '../devices/devices.service'; +import { LocationsService } from '../locations/locations.service'; +import { CommunicationMethodDto } from './dto/communication-method.dto'; +import { ReportFormContextDto } from './dto/report-form-context.dto'; +import { ReportTemplateAlertPointDto } from './dto/report-template-alert-point.dto'; +import { ReportTemplateAssignmentDto } from './dto/report-template-assignment.dto'; +import { ReportTemplateDataProcessingScheduleDto } from './dto/report-template-data-processing-schedule.dto'; +import { ReportTemplateHistoryItemDto } from './dto/report-template-history-item.dto'; +import { ReportTemplateRecipientDto } from './dto/report-template-recipient.dto'; +import { ReportTemplateScheduleDto } from './dto/report-template-schedule.dto'; +import { ReportTemplateDto } from './dto/report-template.dto'; +import { SaveReportTemplateDto } from './dto/save-report-template.dto'; + +type TemplateRow = TableRow<'cw_report_templates'>; +type LocationJoin = { name: string | null }; +type DeviceLocationJoin = { cw_locations?: LocationJoin | LocationJoin[] | null }; +type AssignmentRow = TableRow<'cw_device_report_assignments'> & { + cw_devices?: DeviceLocationJoin | DeviceLocationJoin[] | null; +}; +type ScheduleRow = TableRow<'cw_report_template_schedule'>; +type RecipientRow = TableRow<'cw_report_template_recipients'>; +type AlertPointRow = TableRow<'cw_report_template_alert_points'>; +type DataProcessingScheduleRow = + TableRow<'cw_report_template_data_processing_schedules'>; +type CommunicationMethodRow = TableRow<'communication_methods'>; +type DeviceRow = TableRow<'cw_devices'>; +type DeviceOwnerRow = TableRow<'cw_device_owners'>; + +const STORAGE_BUCKET = 'Reports'; + +interface ManagedDevice { + devEui: string; + name: string | null; + permissionLevel: number | null; + canView: boolean; + canManage: boolean; +} + +interface NormalizedScheduleRow { + endOfDay: boolean; + endOfWeek: boolean; + endOfMonth: boolean; + utcOffset: number; + isActive: boolean; +} + +interface NormalizedRecipientRow { + communicationMethod: number; + email: string | null; + name: string | null; +} + +interface NormalizedAlertPointRow { + name: string; + dataPointKey: string; + operator: string | null; + min: number | null; + max: number | null; + value: number | null; + hexColor: string | null; +} + +interface NormalizedDataProcessingScheduleRow { + dayOfWeek: number; + startTime: string; + endTime: string; + crossesMidnight: boolean; + ruleType: string; + validFrom: string | null; + validTo: string | null; + timezone: string; + isEnabled: boolean; +} + +interface NormalizedSaveRequest { + name: string; + description: string | null; + dataPullInterval: number; + deviceTypeId: number | null; + isActive: boolean; + devEuis: string[]; + schedule: NormalizedScheduleRow[]; + recipients: NormalizedRecipientRow[]; + alertPoints: NormalizedAlertPointRow[]; + dataProcessingSchedules: NormalizedDataProcessingScheduleRow[]; +} + +@Injectable() +export class ReportsNewService { + constructor( + private readonly supabaseService: SupabaseService, + private readonly devicesService: DevicesService, + private readonly locationsService: LocationsService, + ) {} + + async findAll( + jwtPayload: any, + authHeader: string, + searchTerm?: string, + ): Promise { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const viewableDevices = devices.filter((device) => device.canView); + if (viewableDevices.length === 0) return []; + + const client = this.supabaseService.getClient(accessToken); + const { data: assignmentsData, error: assignmentsError } = await client + .from('cw_device_report_assignments') + .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') + .in( + 'dev_eui', + viewableDevices.map((device) => device.devEui), + ); + + if (assignmentsError) { + throw new InternalServerErrorException('Failed to load report assignments'); + } + + const assignments = (assignmentsData ?? []) as AssignmentRow[]; + const templateIds = uniqueValues(assignments.map((row) => row.template_id)); + if (templateIds.length === 0) return []; + + const [templates, schedule, recipients, alertPoints, dpSchedules] = + await Promise.all([ + this.loadTemplatesByIds(accessToken, templateIds), + this.loadScheduleByTemplateIds(accessToken, templateIds), + this.loadRecipientsByTemplateIds(accessToken, templateIds), + this.loadAlertPointsByTemplateIds(accessToken, templateIds), + this.loadDataProcessingSchedulesByTemplateIds(accessToken, templateIds), + ]); + + const reports = buildReportTemplates({ + templates, + assignments, + schedule, + recipients, + alertPoints, + dpSchedules, + devices, + }); + + const search = searchTerm?.trim().toLowerCase(); + if (!search) return reports; + return reports.filter((report) => matchesSearch(report, search)); + } + + async findOne( + id: number, + jwtPayload: any, + authHeader: string, + ): Promise { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const viewableDevices = devices.filter((device) => device.canView); + if (viewableDevices.length === 0) { + throw new NotFoundException('Report template not found'); + } + + const client = this.supabaseService.getClient(accessToken); + const [templateResult, assignmentsResult] = await Promise.all([ + client + .from('cw_report_templates') + .select( + 'created_at, data_pull_interval, description, device_type_id, id, is_active, name', + ) + .eq('id', id) + .maybeSingle(), + client + .from('cw_device_report_assignments') + .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') + .eq('template_id', id) + .in( + 'dev_eui', + viewableDevices.map((device) => device.devEui), + ), + ]); + + if (templateResult.error) { + throw new InternalServerErrorException('Failed to load report template'); + } + if (assignmentsResult.error) { + throw new InternalServerErrorException('Failed to load report assignments'); + } + if (!templateResult.data) { + throw new NotFoundException('Report template not found'); + } + + const assignments = (assignmentsResult.data ?? []) as AssignmentRow[]; + if (assignments.length === 0) { + throw new NotFoundException('Report template not found'); + } + + const [schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ + this.loadScheduleByTemplateIds(accessToken, [id]), + this.loadRecipientsByTemplateIds(accessToken, [id]), + this.loadAlertPointsByTemplateIds(accessToken, [id]), + this.loadDataProcessingSchedulesByTemplateIds(accessToken, [id]), + ]); + + const [report] = buildReportTemplates({ + templates: [templateResult.data as TemplateRow], + assignments, + schedule, + recipients, + alertPoints, + dpSchedules, + devices, + }); + + if (!report) { + throw new NotFoundException('Report template not found'); + } + + return report; + } + + async create( + payload: SaveReportTemplateDto, + jwtPayload: any, + authHeader: string, + ): Promise { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const normalized = normalizeSaveRequest(payload); + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + assertDevicesCanBeManaged(devices, normalized.devEuis); + + const client = this.supabaseService.getClient(accessToken); + const { data: templateData, error: templateError } = await client + .from('cw_report_templates') + .insert({ + name: normalized.name, + description: normalized.description, + data_pull_interval: normalized.dataPullInterval, + device_type_id: normalized.deviceTypeId, + is_active: normalized.isActive, + created_by: userId, + }) + .select( + 'created_at, data_pull_interval, description, device_type_id, id, is_active, name', + ) + .single(); + + if (templateError || !templateData) { + throw new InternalServerErrorException('Failed to create report template'); + } + + try { + await this.replaceTemplateChildren( + accessToken, + templateData.id, + normalized, + ); + } catch (error) { + await this.deleteTemplateBestEffort(accessToken, templateData.id); + throw error; + } + + return this.findOne(templateData.id, jwtPayload, authHeader); + } + + async update( + id: number, + payload: SaveReportTemplateDto, + jwtPayload: any, + authHeader: string, + ): Promise { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const normalized = normalizeSaveRequest(payload); + const existing = await this.findOne(id, jwtPayload, authHeader); + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + + const allDevEuis = uniqueValues([ + ...existing.assignments.map((assignment) => assignment.devEui), + ...normalized.devEuis, + ]); + assertDevicesCanBeManaged(devices, allDevEuis); + + const client = this.supabaseService.getClient(accessToken); + const { error: updateError } = await client + .from('cw_report_templates') + .update({ + name: normalized.name, + description: normalized.description, + data_pull_interval: normalized.dataPullInterval, + device_type_id: normalized.deviceTypeId, + is_active: normalized.isActive, + }) + .eq('id', id); + + if (updateError) { + throw new InternalServerErrorException('Failed to update report template'); + } + + await this.replaceTemplateChildren(accessToken, id, normalized); + + return this.findOne(id, jwtPayload, authHeader); + } + + async remove( + id: number, + jwtPayload: any, + authHeader: string, + ): Promise<{ id: number }> { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const existing = await this.findOne(id, jwtPayload, authHeader); + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + assertDevicesCanBeManaged( + devices, + existing.assignments.map((assignment) => assignment.devEui), + ); + + await this.deleteTemplateChildren(accessToken, id); + + const client = this.supabaseService.getClient(accessToken); + const { error } = await client + .from('cw_report_templates') + .delete() + .eq('id', id); + + if (error) { + throw new InternalServerErrorException('Failed to delete report template'); + } + + return { id }; + } + + async getFormContext( + jwtPayload: any, + authHeader: string, + templateId?: number, + ): Promise { + const [devicesPage, locations, communicationMethods, template] = + await Promise.all([ + this.devicesService.findAll(jwtPayload, authHeader), + this.locationsService.findAll(jwtPayload, authHeader), + this.findAllCommunicationMethods(authHeader), + typeof templateId === 'number' + ? this.findOne(templateId, jwtPayload, authHeader) + : Promise.resolve(null), + ]); + + return { + devices: (devicesPage.data ?? []) as ReportFormContextDto['devices'], + locations: (locations ?? []) as ReportFormContextDto['locations'], + communicationMethods, + template, + }; + } + + async findAllCommunicationMethods( + authHeader: string, + ): Promise { + const accessToken = getAccessToken(authHeader); + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('communication_methods') + .select('communication_method_id, name, is_active') + .eq('is_active', true) + .order('name', { ascending: true }); + + if (error) { + throw new InternalServerErrorException( + 'Failed to load communication methods', + ); + } + + return ((data ?? []) as CommunicationMethodRow[]).map((row) => ({ + communicationMethodId: row.communication_method_id, + name: row.name ?? null, + isActive: row.is_active ?? true, + })); + } + + async getHistory( + id: number, + jwtPayload: any, + authHeader: string, + ): Promise { + // Reuse findOne so a hidden or non-existent template returns 404 instead of + // an empty list. + const template = await this.findOne(id, jwtPayload, authHeader); + const accessToken = getAccessToken(authHeader); + const client = this.supabaseService.getClient(accessToken); + + const devEuis = uniqueValues( + template.assignments.map((assignment) => assignment.devEui), + ); + const deviceNames = new Map( + template.assignments.map((assignment) => [ + assignment.devEui, + assignment.deviceName, + ]), + ); + + const perDevice = await Promise.all( + devEuis.map(async (devEui) => { + const { data, error } = await client.storage + .from(STORAGE_BUCKET) + .list(devEui, { + limit: 110, + offset: 0, + sortBy: { column: 'name', order: 'desc' }, + }); + if (error || !data) return [] as ReportTemplateHistoryItemDto[]; + return data + .filter((item) => item.name && item.name !== '.emptyFolderPlaceholder') + .map( + (item): ReportTemplateHistoryItemDto => ({ + devEui, + deviceName: deviceNames.get(devEui) ?? null, + name: item.name, + id: (item as { id?: string | null }).id ?? null, + createdAt: (item as { created_at?: string | null }).created_at ?? null, + updatedAt: (item as { updated_at?: string | null }).updated_at ?? null, + lastAccessedAt: + (item as { last_accessed_at?: string | null }).last_accessed_at ?? + null, + metadata: + (item as { metadata?: Record | null }).metadata ?? + null, + }), + ); + }), + ); + + return perDevice.flat(); + } + + async getDownloadUrl( + devEui: string, + reportName: string, + jwtPayload: any, + authHeader: string, + ): Promise<{ url: string }> { + const userId = getUserId(jwtPayload); + const accessToken = getAccessToken(authHeader); + const isStaff = isCropwatchStaff(jwtPayload); + + const normalizedDevEui = devEui?.trim(); + const normalizedName = reportName?.trim(); + if (!normalizedDevEui || !normalizedName) { + throw new BadRequestException('dev_eui and reportName are required'); + } + const resolvedName = normalizedName.toLowerCase().endsWith('.pdf') + ? normalizedName + : `${normalizedName}.pdf`; + + const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const device = devices.find((entry) => entry.devEui === normalizedDevEui); + if (!device || !device.canView) { + throw new UnauthorizedException( + 'You do not have permission to download this report', + ); + } + + const storageClient = + this.supabaseService.getAdminClient() ?? + this.supabaseService.getClient(accessToken); + const { data, error } = await storageClient.storage + .from(STORAGE_BUCKET) + .createSignedUrl(`${normalizedDevEui}/${resolvedName}`, 60, { + download: true, + }); + + if (error || !data?.signedUrl) { + throw new InternalServerErrorException( + 'Failed to generate report download URL', + ); + } + + return { url: data.signedUrl }; + } + + private async listManagedDevices( + userId: string, + accessToken: string, + isStaff: boolean, + ): Promise { + const client = this.supabaseService.getClient(accessToken); + const { data, error } = await client + .from('cw_devices') + .select('dev_eui, name, user_id, cw_device_owners(*)'); + + if (error) { + throw new InternalServerErrorException('Failed to load devices'); + } + + const rows = (data ?? []) as Array< + Pick & { + cw_device_owners?: DeviceOwnerRow[] | null; + } + >; + + return rows + .map((row): ManagedDevice => { + const owners = Array.isArray(row.cw_device_owners) + ? row.cw_device_owners + : []; + const ownEntry = owners.find((entry) => entry.user_id === userId); + const directOwner = row.user_id === userId; + const permissionLevel = directOwner + ? 1 + : (ownEntry?.permission_level ?? null); + const canView = + isStaff || directOwner || (permissionLevel != null && permissionLevel <= 3); + const canManage = + isStaff || directOwner || (permissionLevel != null && permissionLevel <= 2); + + return { + devEui: row.dev_eui, + name: row.name?.trim() ? row.name : null, + permissionLevel, + canView, + canManage, + }; + }) + .filter((device) => device.devEui.length > 0); + } + + private async loadTemplatesByIds( + accessToken: string, + templateIds: number[], + ): Promise { + if (templateIds.length === 0) return []; + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_report_templates') + .select( + 'created_at, data_pull_interval, description, device_type_id, id, is_active, name', + ) + .in('id', templateIds); + + if (error) { + throw new InternalServerErrorException('Failed to load report templates'); + } + + return (data ?? []) as TemplateRow[]; + } + + private async loadScheduleByTemplateIds( + accessToken: string, + templateIds: number[], + ): Promise { + if (templateIds.length === 0) return []; + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_report_template_schedule') + .select( + 'created_at, end_of_day, end_of_month, end_of_week, id, is_active, template_id, utc_offset', + ) + .in('template_id', templateIds); + + if (error) { + throw new InternalServerErrorException('Failed to load report schedule'); + } + + return (data ?? []) as ScheduleRow[]; + } + + private async loadRecipientsByTemplateIds( + accessToken: string, + templateIds: number[], + ): Promise { + if (templateIds.length === 0) return []; + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_report_template_recipients') + .select( + 'communication_method, created_at, email, id, name, template_id', + ) + .in('template_id', templateIds); + + if (error) { + throw new InternalServerErrorException('Failed to load report recipients'); + } + + return (data ?? []) as RecipientRow[]; + } + + private async loadAlertPointsByTemplateIds( + accessToken: string, + templateIds: number[], + ): Promise { + if (templateIds.length === 0) return []; + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_report_template_alert_points') + .select( + 'created_at, data_point_key, hex_color, id, max, min, name, operator, template_id, value', + ) + .in('template_id', templateIds); + + if (error) { + throw new InternalServerErrorException('Failed to load report alert points'); + } + + return (data ?? []) as AlertPointRow[]; + } + + private async loadDataProcessingSchedulesByTemplateIds( + accessToken: string, + templateIds: number[], + ): Promise { + if (templateIds.length === 0) return []; + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_report_template_data_processing_schedules') + .select( + 'created_at, crosses_midnight, day_of_week, end_time, id, is_enabled, rule_type, start_time, template_id, timezone, updated_at, valid_from, valid_to', + ) + .in('template_id', templateIds); + + if (error) { + throw new InternalServerErrorException( + 'Failed to load report data processing schedules', + ); + } + + return (data ?? []) as DataProcessingScheduleRow[]; + } + + private async replaceTemplateChildren( + accessToken: string, + templateId: number, + payload: NormalizedSaveRequest, + ): Promise { + await this.deleteTemplateChildren(accessToken, templateId); + + const client = this.supabaseService.getClient(accessToken); + + const assignments = payload.devEuis.map((devEui) => ({ + dev_eui: devEui, + template_id: templateId, + is_active: true, + })); + const { error: assignmentsError } = await client + .from('cw_device_report_assignments') + .insert(assignments); + if (assignmentsError) { + throw new InternalServerErrorException('Failed to save report assignments'); + } + + if (payload.schedule.length > 0) { + const rows = payload.schedule.map((entry) => ({ + template_id: templateId, + end_of_day: entry.endOfDay, + end_of_week: entry.endOfWeek, + end_of_month: entry.endOfMonth, + utc_offset: entry.utcOffset, + is_active: entry.isActive, + })); + const { error } = await client + .from('cw_report_template_schedule') + .insert(rows); + if (error) { + throw new InternalServerErrorException('Failed to save report schedule'); + } + } + + if (payload.recipients.length > 0) { + const rows = payload.recipients.map((entry) => ({ + template_id: templateId, + communication_method: entry.communicationMethod, + email: entry.email, + name: entry.name, + })); + const { error } = await client + .from('cw_report_template_recipients') + .insert(rows); + if (error) { + throw new InternalServerErrorException('Failed to save report recipients'); + } + } + + if (payload.alertPoints.length > 0) { + const rows = payload.alertPoints.map((entry) => ({ + template_id: templateId, + name: entry.name, + data_point_key: entry.dataPointKey, + operator: entry.operator, + min: entry.min, + max: entry.max, + value: entry.value, + hex_color: entry.hexColor, + })); + const { error } = await client + .from('cw_report_template_alert_points') + .insert(rows); + if (error) { + throw new InternalServerErrorException( + 'Failed to save report alert points', + ); + } + } + + if (payload.dataProcessingSchedules.length > 0) { + const rows = payload.dataProcessingSchedules.map((entry) => ({ + template_id: templateId, + day_of_week: entry.dayOfWeek, + start_time: entry.startTime, + end_time: entry.endTime, + crosses_midnight: entry.crossesMidnight, + rule_type: entry.ruleType, + valid_from: entry.validFrom, + valid_to: entry.validTo, + timezone: entry.timezone, + is_enabled: entry.isEnabled, + })); + const { error } = await client + .from('cw_report_template_data_processing_schedules') + .insert(rows); + if (error) { + throw new InternalServerErrorException( + 'Failed to save report data processing schedules', + ); + } + } + } + + private async deleteTemplateChildren( + accessToken: string, + templateId: number, + ): Promise { + const client = this.supabaseService.getClient(accessToken); + const [assignments, schedule, recipients, alertPoints, dpSchedules] = + await Promise.all([ + client + .from('cw_device_report_assignments') + .delete() + .eq('template_id', templateId), + client + .from('cw_report_template_schedule') + .delete() + .eq('template_id', templateId), + client + .from('cw_report_template_recipients') + .delete() + .eq('template_id', templateId), + client + .from('cw_report_template_alert_points') + .delete() + .eq('template_id', templateId), + client + .from('cw_report_template_data_processing_schedules') + .delete() + .eq('template_id', templateId), + ]); + + if (assignments.error) { + throw new InternalServerErrorException('Failed to remove report assignments'); + } + if (schedule.error) { + throw new InternalServerErrorException('Failed to remove report schedule'); + } + if (recipients.error) { + throw new InternalServerErrorException('Failed to remove report recipients'); + } + if (alertPoints.error) { + throw new InternalServerErrorException( + 'Failed to remove report alert points', + ); + } + if (dpSchedules.error) { + throw new InternalServerErrorException( + 'Failed to remove report data processing schedules', + ); + } + } + + private async deleteTemplateBestEffort( + accessToken: string, + templateId: number, + ): Promise { + try { + await this.deleteTemplateChildren(accessToken, templateId); + await this.supabaseService + .getClient(accessToken) + .from('cw_report_templates') + .delete() + .eq('id', templateId); + } catch { + // The template was created but children/template cleanup failed; leaving + // the orphan is preferable to surfacing the cleanup error to the caller. + } + } +} + +function extractLocationName(assignment: AssignmentRow): string | null { + const device = Array.isArray(assignment.cw_devices) + ? assignment.cw_devices[0] + : assignment.cw_devices; + if (!device) return null; + const location = Array.isArray(device.cw_locations) + ? device.cw_locations[0] + : device.cw_locations; + const name = location?.name; + return typeof name === 'string' && name.trim().length > 0 ? name : null; +} + +function buildReportTemplates(args: { + templates: TemplateRow[]; + assignments: AssignmentRow[]; + schedule: ScheduleRow[]; + recipients: RecipientRow[]; + alertPoints: AlertPointRow[]; + dpSchedules: DataProcessingScheduleRow[]; + devices: ManagedDevice[]; +}): ReportTemplateDto[] { + const { templates, assignments, schedule, recipients, alertPoints, dpSchedules, devices } = + args; + + const devicesById = new Map(devices.map((device) => [device.devEui, device])); + const assignmentsByTemplateId = groupBy( + assignments, + (assignment) => assignment.template_id, + ); + const scheduleByTemplateId = groupBy(schedule, (row) => row.template_id); + const recipientsByTemplateId = groupBy(recipients, (row) => row.template_id); + const alertPointsByTemplateId = groupBy(alertPoints, (row) => row.template_id); + const dpSchedulesByTemplateId = groupBy(dpSchedules, (row) => row.template_id); + + return templates + .map((template): ReportTemplateDto | null => { + const templateAssignments = assignmentsByTemplateId.get(template.id) ?? []; + if (templateAssignments.length === 0) return null; + + return { + id: template.id, + name: template.name, + description: template.description, + deviceTypeId: template.device_type_id, + dataPullInterval: template.data_pull_interval, + isActive: template.is_active ?? true, + createdAt: template.created_at, + assignments: templateAssignments.map( + (assignment): ReportTemplateAssignmentDto => { + const device = devicesById.get(assignment.dev_eui); + return { + id: assignment.id, + devEui: assignment.dev_eui, + templateId: assignment.template_id, + isActive: assignment.is_active ?? true, + createdAt: assignment.created_at, + deviceName: device?.name ?? null, + locationName: extractLocationName(assignment), + permissionLevel: device?.permissionLevel ?? null, + }; + }, + ), + schedule: (scheduleByTemplateId.get(template.id) ?? []).map(mapSchedule), + recipients: (recipientsByTemplateId.get(template.id) ?? []).map( + mapRecipient, + ), + alertPoints: (alertPointsByTemplateId.get(template.id) ?? []).map( + mapAlertPoint, + ), + dataProcessingSchedules: (dpSchedulesByTemplateId.get(template.id) ?? []).map( + mapDataProcessingSchedule, + ), + }; + }) + .filter((report): report is ReportTemplateDto => report !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function mapSchedule(row: ScheduleRow): ReportTemplateScheduleDto { + return { + id: row.id, + templateId: row.template_id, + endOfDay: row.end_of_day ?? false, + endOfWeek: row.end_of_week ?? false, + endOfMonth: row.end_of_month ?? false, + utcOffset: row.utc_offset ?? 9, + isActive: row.is_active ?? true, + createdAt: row.created_at, + }; +} + +function mapRecipient(row: RecipientRow): ReportTemplateRecipientDto { + return { + id: row.id, + templateId: row.template_id, + communicationMethod: row.communication_method, + email: row.email, + name: row.name, + createdAt: row.created_at, + }; +} + +function mapAlertPoint(row: AlertPointRow): ReportTemplateAlertPointDto { + return { + id: row.id, + templateId: row.template_id, + name: row.name, + dataPointKey: row.data_point_key, + operator: row.operator, + min: row.min, + max: row.max, + value: row.value, + hexColor: row.hex_color, + createdAt: row.created_at, + }; +} + +function mapDataProcessingSchedule( + row: DataProcessingScheduleRow, +): ReportTemplateDataProcessingScheduleDto { + return { + id: row.id, + templateId: row.template_id, + dayOfWeek: row.day_of_week, + startTime: row.start_time, + endTime: row.end_time, + crossesMidnight: row.crosses_midnight ?? false, + ruleType: row.rule_type ?? 'include', + validFrom: row.valid_from, + validTo: row.valid_to, + timezone: row.timezone ?? 'Asia/Tokyo', + isEnabled: row.is_enabled ?? true, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function normalizeSaveRequest( + payload: SaveReportTemplateDto, +): NormalizedSaveRequest { + const name = (payload.name ?? '').trim(); + if (!name) { + throw new BadRequestException('Report name is required'); + } + + const devEuis = uniqueValues( + (payload.devEuis ?? []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0), + ); + if (devEuis.length === 0) { + throw new BadRequestException('At least one device is required'); + } + + const schedule = (payload.schedule ?? []).map( + (entry): NormalizedScheduleRow => ({ + endOfDay: entry.endOfDay ?? false, + endOfWeek: entry.endOfWeek ?? false, + endOfMonth: entry.endOfMonth ?? false, + utcOffset: + typeof entry.utcOffset === 'number' && Number.isFinite(entry.utcOffset) + ? entry.utcOffset + : 9, + isActive: entry.isActive ?? true, + }), + ); + + const recipients = (payload.recipients ?? []).map( + (entry, index): NormalizedRecipientRow => { + if (!Number.isInteger(entry.communicationMethod)) { + throw new BadRequestException( + `Recipient ${index + 1} needs a communication method`, + ); + } + return { + communicationMethod: entry.communicationMethod, + email: trimOrNull(entry.email), + name: trimOrNull(entry.name), + }; + }, + ); + + const alertPoints = (payload.alertPoints ?? []).map( + (entry, index): NormalizedAlertPointRow => { + const apName = (entry.name ?? '').trim(); + const dataPointKey = (entry.dataPointKey ?? '').trim(); + if (!apName || !dataPointKey) { + throw new BadRequestException( + `Alert point ${index + 1} must include a name and a data point`, + ); + } + return { + name: apName, + dataPointKey, + operator: trimOrNull(entry.operator), + min: numberOrNull(entry.min), + max: numberOrNull(entry.max), + value: numberOrNull(entry.value), + hexColor: trimOrNull(entry.hexColor), + }; + }, + ); + + const dataProcessingSchedules = (payload.dataProcessingSchedules ?? []).map( + (entry, index): NormalizedDataProcessingScheduleRow => { + const startTime = (entry.startTime ?? '').trim(); + const endTime = (entry.endTime ?? '').trim(); + if (!Number.isInteger(entry.dayOfWeek) || !startTime || !endTime) { + throw new BadRequestException( + `Processing window ${index + 1} must include a day, start time, and end time`, + ); + } + return { + dayOfWeek: entry.dayOfWeek, + startTime, + endTime, + crossesMidnight: entry.crossesMidnight ?? false, + ruleType: + typeof entry.ruleType === 'string' && entry.ruleType.trim() + ? entry.ruleType.trim() + : 'include', + validFrom: trimOrNull(entry.validFrom), + validTo: trimOrNull(entry.validTo), + timezone: + typeof entry.timezone === 'string' && entry.timezone.trim() + ? entry.timezone.trim() + : 'Asia/Tokyo', + isEnabled: entry.isEnabled ?? true, + }; + }, + ); + + return { + name, + description: + typeof payload.description === 'string' && payload.description.trim() + ? payload.description.trim() + : null, + dataPullInterval: + typeof payload.dataPullInterval === 'number' && + Number.isFinite(payload.dataPullInterval) && + payload.dataPullInterval > 0 + ? Math.floor(payload.dataPullInterval) + : 30, + deviceTypeId: + typeof payload.deviceTypeId === 'number' && + Number.isFinite(payload.deviceTypeId) + ? payload.deviceTypeId + : null, + isActive: typeof payload.isActive === 'boolean' ? payload.isActive : true, + devEuis, + schedule, + recipients, + alertPoints, + dataProcessingSchedules, + }; +} + +function assertDevicesCanBeManaged( + devices: ManagedDevice[], + devEuis: string[], +): void { + const manageable = new Set( + devices.filter((device) => device.canManage).map((device) => device.devEui), + ); + const missing = devEuis.find((devEui) => !manageable.has(devEui)); + if (missing) { + throw new ForbiddenException( + 'You do not have permission to manage one or more selected devices', + ); + } +} + +function matchesSearch(report: ReportTemplateDto, search: string): boolean { + const deviceText = report.assignments + .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) + .join(' '); + return [report.name, report.description ?? '', deviceText] + .join(' ') + .toLowerCase() + .includes(search); +} + +function trimOrNull(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function numberOrNull(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function groupBy(items: T[], key: (item: T) => TKey): Map { + const result = new Map(); + for (const item of items) { + const groupKey = key(item); + const existing = result.get(groupKey); + if (existing) { + existing.push(item); + } else { + result.set(groupKey, [item]); + } + } + return result; +} + +function uniqueValues(values: T[]): T[] { + return [...new Set(values)]; +} diff --git a/src/v1/rules-new/dto/rule-template-assignment.dto.ts b/src/v1/rules-new/dto/rule-template-assignment.dto.ts index 950f6ad..bb67369 100644 --- a/src/v1/rules-new/dto/rule-template-assignment.dto.ts +++ b/src/v1/rules-new/dto/rule-template-assignment.dto.ts @@ -20,6 +20,9 @@ export class RuleTemplateAssignmentDto { @ApiProperty({ nullable: true, required: false }) deviceName: string | null; + @ApiProperty({ nullable: true, required: false }) + locationName: string | null; + @ApiProperty({ nullable: true, required: false }) permissionLevel: number | null; diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules-new/rules-new.service.ts index 5f139f4..4269922 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules-new/rules-new.service.ts @@ -79,7 +79,7 @@ export class RulesNewService { const client = this.supabaseService.getClient(accessToken); const { data: assignmentsData, error: assignmentsError } = await client .from('cw_device_rule_assignments') - .select('created_at, dev_eui, id, is_active, template_id') + .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') .in( 'dev_eui', viewableDevices.map((device) => device.devEui), @@ -694,6 +694,7 @@ function buildRuleTemplates(args: { isActive: assignment.is_active ?? true, createdAt: assignment.created_at, deviceName: device?.name ?? null, + locationName: readAssignmentLocationName(assignment), permissionLevel: device?.permissionLevel ?? null, state: state ? mapState(state) : null, }; @@ -734,6 +735,19 @@ function mapAction(row: ActionRow): RuleTemplateActionDto { }; } +function unwrapJoin(raw: unknown): Record | null { + if (Array.isArray(raw)) return (raw[0] as Record) ?? null; + return (raw as Record | null) ?? null; +} + +// findAll embeds cw_devices(cw_locations(name)) on each assignment row. +function readAssignmentLocationName(assignment: AssignmentRow): string | null { + const device = unwrapJoin((assignment as { cw_devices?: unknown }).cw_devices); + const location = unwrapJoin(device?.cw_locations); + const name = location?.name; + return typeof name === 'string' && name.trim().length > 0 ? name : null; +} + function mapState(row: StateRow): RuleTemplateStateDto { return { id: row.id,