From 30e44c816c58952d1164845316ddcc8d22aeb5be Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 1 May 2026 00:02:56 +0900 Subject: [PATCH 1/7] finally moving to lavinmq --- messages/en.json | 3 + messages/ja.json | 3 + package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardDeviceCards.svelte | 34 +++-- .../components/dashboard/device-cards.spec.ts | 129 +++++++++++++++++- src/lib/components/dashboard/device-cards.ts | 108 ++++++++++++++- src/lib/devices/relay-telemetry.ts | 2 +- 8 files changed, 263 insertions(+), 28 deletions(-) diff --git a/messages/en.json b/messages/en.json index eb8285c3..01464d19 100644 --- a/messages/en.json +++ b/messages/en.json @@ -791,6 +791,9 @@ "display_relay_history": "Relay History", "display_relay_one": "Relay 1", "display_relay_two": "Relay 2", + "display_relay_state_on": "ON", + "display_relay_state_off": "OFF", + "display_relay_state_unknown": "—", "display_save_note": "Save Note", "display_searchable_sortable": "Searchable, sortable", "display_searchable_sortable_data": "Searchable, sortable data", diff --git a/messages/ja.json b/messages/ja.json index d5786ea2..8074c110 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -791,6 +791,9 @@ "display_relay_history": "リレー履歴", "display_relay_one": "リレー 1", "display_relay_two": "リレー 2", + "display_relay_state_on": "ON", + "display_relay_state_off": "OFF", + "display_relay_state_unknown": "—", "display_save_note": "メモを保存", "display_searchable_sortable": "検索・並べ替え可能", "display_searchable_sortable_data": "検索・並べ替え可能なデータ", diff --git a/package.json b/package.json index cffd1312..24d1462d 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.84", + "@cropwatchdevelopment/cwui": "0.1.86", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3495eaba..ae01176d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.84 - version: 0.1.84(svelte@5.53.0) + specifier: 0.1.86 + version: 0.1.86(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.84': - resolution: {integrity: sha512-aUKXyn0JaNZITCrr1h3waBIKGvIR16g5L+0GwS1PJyzGpzUdIstu3NB5er9H8twFg2drLj2eNc8n/aOrbOlEHg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.84/028c5318b58155ec7cd7cb09cfeed35f8ba47bbe} + '@cropwatchdevelopment/cwui@0.1.86': + resolution: {integrity: sha512-0OepkYq8ThBXXrEFvCsenhSr4wT7NxLmoM/gtCC6UtbotI9eNTn4QU/MeeNE5BXQfpoQRKYsCDop6qixT7uwNQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.86/96ca0fa5fe347545897d6ca6ae7cd7a1a2c4369e} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.84(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.86(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte index 09ebeeba..144d2c91 100644 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte @@ -17,11 +17,14 @@ buildDashboardLocationSensorCards, buildDeviceExpandedDetailRows, buildDeviceLoadingDetailRows, + buildRelayExpandedDetailRows, DASHBOARD_SENSOR_CARD_LOCATION_BATCH_SIZE, DASHBOARD_SENSOR_CARD_PREFETCH_REMAINING, + hasRelayKeys, type DashboardLocationSensorCard, type DashboardSensorCardEntry } from './device-cards'; + import { isRelayTable } from '$lib/config/deviceTables'; import { getDashboardDeviceNextRefreshDelayMs, refreshDashboardDevice @@ -463,11 +466,11 @@ const createdAtForRow = Number.isFinite(liveCreatedAtMs) ? liveCreatedAt : freshData.created_at; - expandedDetailRowsByDevEui[devEui] = buildDeviceExpandedDetailRows( - { ...freshData, created_at: createdAtForRow }, - typeConfig, - sensor.sensor.label - ); + const dataForRows = { ...freshData, created_at: createdAtForRow }; + const isRelay = isRelayTable(liveDevice.data_table) || hasRelayKeys(dataForRows); + expandedDetailRowsByDevEui[devEui] = isRelay + ? buildRelayExpandedDetailRows(dataForRows, sensor.sensor.label) + : buildDeviceExpandedDetailRows(dataForRows, typeConfig, sensor.sensor.label); } function handleSensorCollapse(sensor: DashboardSensorCardEntry) { @@ -512,14 +515,17 @@ const sensorEntry = locationCards .flatMap((c) => c.sensors) .find((s) => s.devEui === devEui); - const typeConfig = sensorEntry - ? resolveDeviceTypeConfig(sensorEntry.sourceDevice, app.deviceTypeLookup) - : undefined; - expandedDetailRowsByDevEui[devEui] = buildDeviceExpandedDetailRows( - coercedData, - typeConfig, - sensorEntry?.sensor.label ?? devEui - ); + const rowLabel = sensorEntry?.sensor.label ?? devEui; + const isRelay = isRelayTable(latestDevice.data_table) || hasRelayKeys(coercedData); + expandedDetailRowsByDevEui[devEui] = isRelay + ? buildRelayExpandedDetailRows(coercedData, rowLabel) + : buildDeviceExpandedDetailRows( + coercedData, + sensorEntry + ? resolveDeviceTypeConfig(sensorEntry.sourceDevice, app.deviceTypeLookup) + : undefined, + rowLabel + ); } } catch (error) { console.error(`Failed to refresh device ${devEui}:`, error); @@ -561,9 +567,11 @@ primaryValue={sensor.sensor.primaryValue} primaryUnit={sensor.sensor.primaryUnit} primary_icon={sensor.sensor.primary_icon} + primaryLabel={sensor.sensor.primaryLabel} secondaryValue={sensor.sensor.secondaryValue} secondaryUnit={sensor.sensor.secondaryUnit} secondary_icon={sensor.sensor.secondary_icon} + secondaryLabel={sensor.sensor.secondaryLabel} lastUpdated={sensor.sensor.lastUpdated} expireAfterMinutes={sensor?.sourceDevice?.raw_data?.default_upload_interval ?? 10} class="dashboard-device-cards__sensor-card" diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts index dd275465..2e83acc4 100644 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { LocationDto } from '$lib/api/api.dtos'; import type { IDevice } from '$lib/interfaces/device.interface'; -import { buildDashboardLocationSensorCards } from './device-cards'; +import { m } from '$lib/paraglide/messages.js'; +import { + buildDashboardLocationSensorCards, + buildRelayExpandedDetailRows +} from './device-cards'; import { DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES, getDashboardDeviceNextRefreshDelayMs @@ -129,6 +133,129 @@ describe('device-cards helpers', () => { vi.useRealTimers(); }); + it('renders ON/OFF labels on the primary fields for relay devices', () => { + const recentNow = new Date('2026-04-30T12:00:00.000Z').getTime(); + const recentCreatedAt = new Date('2026-04-30T11:59:30.000Z'); + + const cards = buildDashboardLocationSensorCards( + [ + { + dev_eui: 'relay-mixed', + name: 'Greenhouse Relay', + location_name: 'North Bay', + group: 'relay', + data_table: 'cw_relay_data', + created_at: recentCreatedAt, + has_primary_data: true, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_humidity: null, + location_id: 7, + raw_data: { relay_1: true, relay_2: false } + }, + { + dev_eui: 'relay-numeric', + name: 'Pump Relay', + location_name: 'North Bay', + group: 'relay', + data_table: 'cw_relay_data', + created_at: recentCreatedAt, + has_primary_data: true, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_humidity: null, + location_id: 7, + raw_data: { relay_1: 1, relay_2: 0 } + }, + { + dev_eui: 'relay-blank', + name: 'Idle Relay', + location_name: 'North Bay', + group: 'relay', + data_table: 'cw_relay_data', + created_at: recentCreatedAt, + has_primary_data: true, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_humidity: null, + location_id: 7 + } + ], + [{ location_id: 7, name: 'North Bay' } as LocationDto], + recentNow + ); + + const sensors = cards[0]!.sensors.map((entry) => entry.sensor); + const [greenhouse, idle, pump] = sensors; + + expect(greenhouse).toMatchObject({ + label: 'Greenhouse Relay', + primaryValue: 1, + primaryLabel: m.display_relay_state_on(), + primary_icon: 'relay', + secondaryValue: 0, + secondaryLabel: m.display_relay_state_off(), + secondary_icon: 'relay', + status: 'online' + }); + expect(greenhouse.primaryUnit).toBe(m.display_relay_one()); + expect(greenhouse.secondaryUnit).toBe(m.display_relay_two()); + + expect(pump).toMatchObject({ + primaryValue: 1, + primaryLabel: m.display_relay_state_on(), + secondaryValue: 0, + secondaryLabel: m.display_relay_state_off() + }); + + expect(idle).toMatchObject({ + primaryValue: 0, + primaryLabel: m.display_relay_state_unknown(), + secondaryValue: 0, + secondaryLabel: m.display_relay_state_unknown() + }); + }); + + it('builds expanded relay detail rows with the last-update timestamp', () => { + const createdAt = new Date('2026-04-30T11:59:30.000Z'); + const rows = buildRelayExpandedDetailRows( + { relay_1: true, relay_2: false, created_at: createdAt }, + 'Greenhouse Relay' + ); + + expect(rows).toHaveLength(3); + expect(rows[0]).toMatchObject({ + id: 'Greenhouse Relay-relay_1', + value: m.display_relay_state_on(), + icon: 'relay' + }); + expect(rows[1]).toMatchObject({ + id: 'Greenhouse Relay-relay_2', + value: m.display_relay_state_off(), + icon: 'relay' + }); + expect(rows[2]).toMatchObject({ + id: 'Greenhouse Relay-updated', + icon: 'timer' + }); + }); + + it('omits the last-update row when created_at is missing or invalid', () => { + expect(buildRelayExpandedDetailRows({ relay_1: true, relay_2: true }, 'X')).toHaveLength(2); + expect( + buildRelayExpandedDetailRows({ relay_1: true, relay_2: true, created_at: 'nope' }, 'X') + ).toHaveLength(2); + }); + + it('renders unknown labels in expanded rows when relay values are absent', () => { + const rows = buildRelayExpandedDetailRows({}, 'X'); + expect(rows[0].value).toBe(m.display_relay_state_unknown()); + expect(rows[1].value).toBe(m.display_relay_state_unknown()); + }); + it('keeps metadata-only devices offline without arming an overdue timer', () => { const cards = buildDashboardLocationSensorCards( [ diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index 88864138..e8faa28e 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -1,6 +1,9 @@ import type { CwSensorCardDetailRow, CwSensorCardDevice } from '@cropwatchdevelopment/cwui'; import type { LocationDto } from '$lib/api/api.dtos'; +import { isRelayTable } from '$lib/config/deviceTables'; +import { coerceRelayValue } from '$lib/devices/relay-telemetry'; import type { IDevice } from '$lib/interfaces/device.interface'; +import { m } from '$lib/paraglide/messages.js'; import { resolveDeviceTypeConfig, type DeviceTypeConfig, @@ -30,12 +33,12 @@ export interface DashboardLocationSensorCard { sensors: DashboardSensorCardEntry[]; } -function getDeviceBaseLabel(device: IDevice): string { - return device.name.trim() || device.dev_eui; -} +// function getDeviceBaseLabel(device: IDevice): string { +// return device.name.trim() || device.dev_eui; +// } function getDeviceLabel(device: IDevice, duplicateCounts: Map): string { - const baseLabel = getDeviceBaseLabel(device); + const baseLabel = device.name; return (duplicateCounts.get(baseLabel) ?? 0) > 1 ? `${baseLabel} (${device.dev_eui})` : baseLabel; } @@ -48,6 +51,58 @@ function getSensorStorageKey(device: IDevice): string { return `dashboard-device-card:${device.dev_eui}`; } +function getRelayStateLabel(value: boolean | null): string { + if (value === true) return m.display_relay_state_on(); + if (value === false) return m.display_relay_state_off(); + return m.display_relay_state_unknown(); +} + +export function hasRelayKeys(payload: Record | null | undefined): boolean { + return payload != null && ('relay_1' in payload || 'relay_2' in payload); +} + +function isRelayDevice(device: IDevice): boolean { + return isRelayTable(device.data_table) || hasRelayKeys(device.raw_data); +} + +export function buildRelayExpandedDetailRows( + rawData: Record, + deviceLabel: string +): CwSensorCardDetailRow[] { + const relay1 = coerceRelayValue(rawData.relay_1); + const relay2 = coerceRelayValue(rawData.relay_2); + const rows: CwSensorCardDetailRow[] = [ + { + id: `${deviceLabel}-relay_1`, + label: m.display_relay_one(), + value: getRelayStateLabel(relay1), + icon: 'relay' + }, + { + id: `${deviceLabel}-relay_2`, + label: m.display_relay_two(), + value: getRelayStateLabel(relay2), + icon: 'relay' + } + ]; + + const createdAt = rawData.created_at; + if (createdAt != null) { + const lastUpdatedDate = createdAt instanceof Date ? createdAt : new Date(String(createdAt)); + if (Number.isFinite(lastUpdatedDate.getTime())) { + rows.push({ + id: `${deviceLabel}-updated`, + label: 'Last Update', + icon: 'timer', + lastUpdated: lastUpdatedDate, + expectedUpdateAfter: DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES + }); + } + } + + return rows; +} + function buildUnavailableDetailRows(label: string, typeConfig: DeviceTypeConfig | undefined): CwSensorCardDetailRow[] { const primaryLabel = typeConfig?.primary_data_key ?? 'Temperature'; const secondaryLabel = typeConfig?.secondary_data_key ?? 'Humidity'; @@ -194,6 +249,41 @@ export function buildDeviceExpandedDetailRows( return rows; } +function buildRelaySensorCardEntry( + device: IDevice, + label: string, + nowMs: number +): DashboardSensorCardEntry { + const relay1 = coerceRelayValue(device.raw_data?.relay_1); + const relay2 = coerceRelayValue(device.raw_data?.relay_2); + const relayToValue = (value: boolean | null): number => (value === true ? 1 : 0); + + return { + id: `sensor:${device.dev_eui}`, + storageKey: getSensorStorageKey(device), + devEui: device.dev_eui, + locationId: Number(device.location_id), + sourceDevice: device, + sensor: { + label, + primaryValue: relayToValue(relay1), + primaryLabel: getRelayStateLabel(relay1), + primaryUnit: m.display_relay_one(), + primary_icon: 'relay', + secondaryValue: relayToValue(relay2), + secondaryLabel: getRelayStateLabel(relay2), + secondaryUnit: m.display_relay_two(), + secondary_icon: 'relay', + status: getDeviceStatus(device, nowMs), + lastUpdated: device.created_at, + detailRows: buildRelayExpandedDetailRows( + { ...(device.raw_data ?? {}), created_at: device.created_at }, + label + ) + } satisfies CwSensorCardDevice + }; +} + function buildDashboardSensorCardEntry( device: IDevice, duplicateCounts: Map, @@ -201,6 +291,11 @@ function buildDashboardSensorCardEntry( deviceTypeLookup?: DeviceTypeLookup ): DashboardSensorCardEntry { const label = getDeviceLabel(device, duplicateCounts); + + if (isRelayDevice(device)) { + return buildRelaySensorCardEntry(device, label, nowMs); + } + const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); if (device.has_primary_data === false) { @@ -223,7 +318,6 @@ function buildDashboardSensorCardEntry( const rawPrimary = device.raw_data?.[typeConfig?.primary_data_key]; const rawSecondary = device.raw_data?.[typeConfig?.secondary_data_key] || null; - console.log(device); return { id: `sensor:${device.dev_eui}`, storageKey: getSensorStorageKey(device), @@ -271,7 +365,7 @@ export function buildDashboardLocationSensorCards( .map(([locationId, locationDevices]) => { const sortedLocationDevices = [...locationDevices].sort( (left, right) => - getDeviceBaseLabel(left).localeCompare(getDeviceBaseLabel(right), undefined, { + left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' }) || @@ -283,7 +377,7 @@ export function buildDashboardLocationSensorCards( const duplicateCounts = new Map(); for (const device of sortedLocationDevices) { - const baseLabel = getDeviceBaseLabel(device); + const baseLabel = device.name; duplicateCounts.set(baseLabel, (duplicateCounts.get(baseLabel) ?? 0) + 1); } diff --git a/src/lib/devices/relay-telemetry.ts b/src/lib/devices/relay-telemetry.ts index a72a97e9..c9e8c354 100644 --- a/src/lib/devices/relay-telemetry.ts +++ b/src/lib/devices/relay-telemetry.ts @@ -4,7 +4,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function coerceRelayValue(value: unknown): boolean | null { +export function coerceRelayValue(value: unknown): boolean | null { if (typeof value === 'boolean') return value; if (value === 1) return true; if (value === 0) return false; From 12db427fe6d954b3dfb3a9225e1a714563917515 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 1 May 2026 12:38:34 +0900 Subject: [PATCH 2/7] fixed stupid width issue --- messages/en.json | 57 ++ messages/ja.json | 57 ++ src/lib/components/layout/AppPage.svelte | 1 - src/lib/i18n/options.ts | 9 + src/lib/rules-new/rule-template-client.ts | 164 ++++ .../rules-new/rule-template-evaluator.spec.ts | 51 + src/lib/rules-new/rule-template-evaluator.ts | 157 +++ src/lib/rules-new/rule-template.types.ts | 79 ++ src/lib/server/rules-new/route-helpers.ts | 48 + .../rules-new/rule-template.repository.ts | 917 ++++++++++++++++++ src/routes/Sidebar.svelte | 7 + src/routes/api/rules-new/+server.ts | 36 + src/routes/api/rules-new/[id]/+server.ts | 56 ++ src/routes/rules-new/+page.server.ts | 7 + src/routes/rules-new/+page.svelte | 230 +++++ .../rules-new/DeleteRuleTemplateDialog.svelte | 87 ++ src/routes/rules-new/RuleTemplateForm.svelte | 675 +++++++++++++ src/routes/rules-new/RuleTemplateTest.svelte | 239 +++++ src/routes/rules-new/create/+page.server.ts | 16 + src/routes/rules-new/create/+page.svelte | 37 + .../rules-new/edit/[id]/+page.server.ts | 39 + src/routes/rules-new/edit/[id]/+page.svelte | 52 + 22 files changed, 3020 insertions(+), 1 deletion(-) create mode 100644 src/lib/rules-new/rule-template-client.ts create mode 100644 src/lib/rules-new/rule-template-evaluator.spec.ts create mode 100644 src/lib/rules-new/rule-template-evaluator.ts create mode 100644 src/lib/rules-new/rule-template.types.ts create mode 100644 src/lib/server/rules-new/route-helpers.ts create mode 100644 src/lib/server/rules-new/rule-template.repository.ts create mode 100644 src/routes/api/rules-new/+server.ts create mode 100644 src/routes/api/rules-new/[id]/+server.ts create mode 100644 src/routes/rules-new/+page.server.ts create mode 100644 src/routes/rules-new/+page.svelte create mode 100644 src/routes/rules-new/DeleteRuleTemplateDialog.svelte create mode 100644 src/routes/rules-new/RuleTemplateForm.svelte create mode 100644 src/routes/rules-new/RuleTemplateTest.svelte create mode 100644 src/routes/rules-new/create/+page.server.ts create mode 100644 src/routes/rules-new/create/+page.svelte create mode 100644 src/routes/rules-new/edit/[id]/+page.server.ts create mode 100644 src/routes/rules-new/edit/[id]/+page.svelte diff --git a/messages/en.json b/messages/en.json index 01464d19..7a0e1fbe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -323,6 +323,63 @@ "rules_update_failed": "Failed to update rule. Please try again.", "rules_invalid_rule_id": "Invalid rule ID", "rules_rule_not_found": "Rule not found", + "rules_new_page_title": "New Rules - CropWatch", + "rules_new_create_page_title": "Create Rule Template - CropWatch", + "rules_new_edit_page_title": "Edit Rule Template - CropWatch", + "rules_new_configured_templates": "Rule Templates", + "rules_new_create_template": "Create Rule Template", + "rules_new_edit_template": "Edit Rule Template", + "rules_new_delete_template": "Delete Rule Template", + "rules_new_template_id": "Template ID", + "rules_new_description_placeholder": "Optional notes for the team", + "rules_new_active_rule": "Active rule", + "rules_new_active_rule_description": "Active templates are evaluated by the alert handler.", + "rules_new_status": "Status", + "rules_new_active": "Active", + "rules_new_inactive": "Inactive", + "rules_new_assigned_devices": "Assigned Devices", + "rules_new_triggered_devices": "Triggered Devices", + "rules_new_actions": "Actions", + "rules_new_action_type": "Action Type", + "rules_new_no_assignments": "No assigned devices", + "rules_new_summary_more": "{summary}, +{count} more", + "rules_new_step_template": "1. Template", + "rules_new_step_template_subtitle": "Name the template and control whether it is evaluated.", + "rules_new_step_assignments": "2. Device Assignments", + "rules_new_step_assignments_subtitle": "Choose the devices that should use this template.", + "rules_new_assignment_number": "Assignment {count}", + "rules_new_add_device_assignment": "Add Device Assignment", + "rules_new_duplicate_devices": "Each device can only be assigned once.", + "rules_new_step_actions": "4. Alert Actions", + "rules_new_step_actions_subtitle": "Choose what the alert handler receives when the template matches.", + "rules_new_action_number": "Action {count}", + "rules_new_add_action": "Add Action", + "rules_new_test_rule": "Test Rule", + "rules_new_test_rule_subtitle": "Paste a decoded payload to preview trigger and reset behavior.", + "rules_new_test_payload": "Decoded Payload JSON", + "rules_new_test_button": "Test Rule", + "rules_new_test_matches": "This payload triggers the rule.", + "rules_new_test_no_match": "This payload does not trigger the rule.", + "rules_new_test_can_reset": "This payload reaches the reset point.", + "rules_new_test_missing_criteria": "Add at least one condition before testing.", + "rules_new_test_parse_failed": "Enter valid JSON before testing.", + "rules_new_test_payload_object_required": "The payload must be a JSON object.", + "rules_new_test_actual_value": "Actual value", + "rules_new_test_trigger_passed": "Trigger passed", + "rules_new_test_trigger_failed": "Trigger failed", + "rules_new_test_reset_passed": "Reset passed", + "rules_new_test_reset_failed": "Reset failed", + "rules_new_test_reason_unsupported_operator": "Unsupported operator.", + "rules_new_test_reason_non_comparable_value": "The payload value cannot be compared as a number.", + "rules_new_test_reason_reset_not_reached": "The reset value has not been reached.", + "rules_new_created_success": "Rule template \"{name}\" created successfully.", + "rules_new_updated_success": "Rule template \"{name}\" updated successfully.", + "rules_new_deleted_success": "Rule template \"{name}\" deleted successfully.", + "rules_new_load_failed": "Unable to load rule templates.", + "rules_new_save_failed": "Unable to save this rule template.", + "rules_new_delete_failed": "Unable to delete this rule template.", + "rules_new_invalid_template_id": "Invalid rule template ID.", + "rules_new_rule_template_not_found": "Rule template not found.", "reports_page_title": "Reports - CropWatch", "reports_weekly_reports": "Weekly Reports", "reports_for_device": "For Device", diff --git a/messages/ja.json b/messages/ja.json index 8074c110..885b03dc 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -323,6 +323,63 @@ "rules_update_failed": "ルールの更新に失敗しました。もう一度お試しください。", "rules_invalid_rule_id": "無効なルール ID です", "rules_rule_not_found": "ルールが見つかりません", + "rules_new_page_title": "新ルール - CropWatch", + "rules_new_create_page_title": "ルールテンプレートを作成 - CropWatch", + "rules_new_edit_page_title": "ルールテンプレートを編集 - CropWatch", + "rules_new_configured_templates": "ルールテンプレート", + "rules_new_create_template": "ルールテンプレートを作成", + "rules_new_edit_template": "ルールテンプレートを編集", + "rules_new_delete_template": "ルールテンプレートを削除", + "rules_new_template_id": "テンプレート ID", + "rules_new_description_placeholder": "チーム向けのメモ (任意)", + "rules_new_active_rule": "ルールを有効化", + "rules_new_active_rule_description": "有効なテンプレートはアラート処理で評価されます。", + "rules_new_status": "ステータス", + "rules_new_active": "有効", + "rules_new_inactive": "無効", + "rules_new_assigned_devices": "割り当てデバイス", + "rules_new_triggered_devices": "発火中デバイス", + "rules_new_actions": "アクション", + "rules_new_action_type": "アクションタイプ", + "rules_new_no_assignments": "割り当てデバイスなし", + "rules_new_summary_more": "{summary}、他 {count} 件", + "rules_new_step_template": "1. テンプレート", + "rules_new_step_template_subtitle": "テンプレート名と評価の有効/無効を設定します。", + "rules_new_step_assignments": "2. デバイス割り当て", + "rules_new_step_assignments_subtitle": "このテンプレートを使用するデバイスを選択します。", + "rules_new_assignment_number": "割り当て {count}", + "rules_new_add_device_assignment": "デバイス割り当てを追加", + "rules_new_duplicate_devices": "同じデバイスは一度だけ割り当てできます。", + "rules_new_step_actions": "4. アラートアクション", + "rules_new_step_actions_subtitle": "テンプレートが一致したときにアラート処理へ渡す内容を選択します。", + "rules_new_action_number": "アクション {count}", + "rules_new_add_action": "アクションを追加", + "rules_new_test_rule": "ルールをテスト", + "rules_new_test_rule_subtitle": "デコード済みペイロードを貼り付けて、発火とリセットの挙動を確認します。", + "rules_new_test_payload": "デコード済みペイロード JSON", + "rules_new_test_button": "ルールをテスト", + "rules_new_test_matches": "このペイロードはルールを発火します。", + "rules_new_test_no_match": "このペイロードはルールを発火しません。", + "rules_new_test_can_reset": "このペイロードはリセット条件に到達しています。", + "rules_new_test_missing_criteria": "テスト前に条件を 1 つ以上追加してください。", + "rules_new_test_parse_failed": "テスト前に有効な JSON を入力してください。", + "rules_new_test_payload_object_required": "ペイロードは JSON オブジェクトである必要があります。", + "rules_new_test_actual_value": "実測値", + "rules_new_test_trigger_passed": "発火条件一致", + "rules_new_test_trigger_failed": "発火条件不一致", + "rules_new_test_reset_passed": "リセット条件一致", + "rules_new_test_reset_failed": "リセット条件不一致", + "rules_new_test_reason_unsupported_operator": "未対応の演算子です。", + "rules_new_test_reason_non_comparable_value": "ペイロードの値を数値として比較できません。", + "rules_new_test_reason_reset_not_reached": "リセット値に到達していません。", + "rules_new_created_success": "ルールテンプレート「{name}」を作成しました。", + "rules_new_updated_success": "ルールテンプレート「{name}」を更新しました。", + "rules_new_deleted_success": "ルールテンプレート「{name}」を削除しました。", + "rules_new_load_failed": "ルールテンプレートを読み込めません。", + "rules_new_save_failed": "このルールテンプレートを保存できません。", + "rules_new_delete_failed": "このルールテンプレートを削除できません。", + "rules_new_invalid_template_id": "無効なルールテンプレート ID です。", + "rules_new_rule_template_not_found": "ルールテンプレートが見つかりません。", "reports_page_title": "レポート - CropWatch", "reports_weekly_reports": "週次レポート", "reports_for_device": "対象デバイス", diff --git a/src/lib/components/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte index 0cba639e..ace48324 100644 --- a/src/lib/components/layout/AppPage.svelte +++ b/src/lib/components/layout/AppPage.svelte @@ -41,7 +41,6 @@ .app-page__shell { flex: 1 0 auto; width: 100%; - max-width: var(--app-page-max-width); margin-inline: auto; min-width: 0; display: flex; diff --git a/src/lib/i18n/options.ts b/src/lib/i18n/options.ts index d00310bb..fdd07562 100644 --- a/src/lib/i18n/options.ts +++ b/src/lib/i18n/options.ts @@ -50,6 +50,15 @@ export function getRuleSendMethodOptions() { ]; } +export function getRuleTemplateActionTypeOptions() { + return [ + { label: m.rule_notifier_email(), value: 'email' }, + { label: m.rule_notifier_sms(), value: 'sms' }, + { label: m.rule_notifier_push(), value: 'push' }, + { label: m.rule_notifier_discord(), value: 'discord' } + ]; +} + export function getRuleSubjectOptions() { return [ { label: m.rule_subject_temperature(), value: 'temperature_c' }, diff --git a/src/lib/rules-new/rule-template-client.ts b/src/lib/rules-new/rule-template-client.ts new file mode 100644 index 00000000..47d7a098 --- /dev/null +++ b/src/lib/rules-new/rule-template-client.ts @@ -0,0 +1,164 @@ +import type { + RuleTemplateDto, + RuleTemplateListQuery, + RuleTemplateSaveRequest +} from './rule-template.types'; + +type FetchLike = typeof fetch; + +export class RuleTemplateApiError extends Error { + public readonly status: number; + public readonly payload: unknown; + + public constructor(status: number, payload: unknown) { + super(`Rule template API request failed (${status})`); + this.name = 'RuleTemplateApiError'; + this.status = status; + this.payload = payload; + } +} + +export async function listRuleTemplates( + query: RuleTemplateListQuery = {}, + options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} +): Promise { + const search = query.search?.trim(); + const params = new URLSearchParams(); + if (search) params.set('search', search); + + const suffix = params.toString(); + return requestJson(`/api/rules-new${suffix ? `?${suffix}` : ''}`, { + fetchFn: options.fetchFn, + signal: options.signal + }); +} + +export function getRuleTemplate( + id: number, + options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} +): Promise { + return requestJson(`/api/rules-new/${encodeURIComponent(String(id))}`, { + fetchFn: options.fetchFn, + signal: options.signal + }); +} + +export function createRuleTemplate( + payload: RuleTemplateSaveRequest, + options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} +): Promise { + return requestJson('/api/rules-new', { + method: 'POST', + body: payload, + fetchFn: options.fetchFn, + signal: options.signal + }); +} + +export function updateRuleTemplate( + id: number, + payload: RuleTemplateSaveRequest, + options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} +): Promise { + return requestJson(`/api/rules-new/${encodeURIComponent(String(id))}`, { + method: 'PATCH', + body: payload, + fetchFn: options.fetchFn, + signal: options.signal + }); +} + +export function deleteRuleTemplate( + id: number, + options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} +): Promise<{ id: number }> { + return requestJson<{ id: number }>(`/api/rules-new/${encodeURIComponent(String(id))}`, { + method: 'DELETE', + fetchFn: options.fetchFn, + signal: options.signal + }); +} + +export function readRuleTemplateApiError(error: unknown, fallback: string): string { + if (error instanceof RuleTemplateApiError) { + return readMessage(error.payload) ?? fallback; + } + + if (error instanceof Error) { + const message = error.message.trim(); + return message.length > 0 ? message : fallback; + } + + return fallback; +} + +async function requestJson( + path: string, + options: { + method?: string; + body?: unknown; + fetchFn?: FetchLike; + signal?: AbortSignal; + } = {} +): Promise { + const fetchFn = options.fetchFn ?? fetch; + const headers = new Headers(); + headers.set('Accept', 'application/json'); + + let body: string | undefined; + if (options.body !== undefined) { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(options.body); + } + + const response = await fetchFn(path, { + method: options.method ?? 'GET', + headers, + body, + signal: options.signal, + credentials: 'same-origin' + }); + const payload = await parsePayload(response); + + if (!response.ok) { + throw new RuleTemplateApiError(response.status, payload); + } + + return payload as T; +} + +async function parsePayload(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function readMessage(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + if (Array.isArray(value)) { + for (const item of value) { + const message = readMessage(item); + if (message) return message; + } + return null; + } + + if (!isRecord(value)) { + return null; + } + + return readMessage(value.message) ?? readMessage(value.error) ?? readMessage(value.payload); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/lib/rules-new/rule-template-evaluator.spec.ts b/src/lib/rules-new/rule-template-evaluator.spec.ts new file mode 100644 index 00000000..52df7966 --- /dev/null +++ b/src/lib/rules-new/rule-template-evaluator.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateRuleTemplateCriteria } from './rule-template-evaluator'; +import type { RuleTemplateCriterionInput } from './rule-template.types'; + +describe('evaluateRuleTemplateCriteria', () => { + it('matches only when every trigger condition passes', () => { + const result = evaluateRuleTemplateCriteria( + [ + criterion({ subject: 'temperature_c', operator: '>', triggerValue: 30, resetValue: 25 }), + criterion({ subject: 'humidity', operator: '<=', triggerValue: 80, resetValue: 85 }) + ], + { temperature_c: 31, humidity: 80 } + ); + + expect(result.matches).toBe(true); + expect(result.criteria.every((item) => item.matched)).toBe(true); + }); + + it('reports non-comparable values without throwing', () => { + const result = evaluateRuleTemplateCriteria( + [criterion({ subject: 'temperature_c', operator: '>', triggerValue: 30, resetValue: 25 })], + { temperature_c: 'not-a-number' } + ); + + expect(result.matches).toBe(false); + expect(result.criteria[0]?.reason).toBe('non_comparable_value'); + }); + + it('uses reset direction compatible with the alert handler', () => { + const highThreshold = evaluateRuleTemplateCriteria( + [criterion({ subject: 'temperature_c', operator: '>', triggerValue: 30, resetValue: 25 })], + { temperature_c: 25 } + ); + const lowThreshold = evaluateRuleTemplateCriteria( + [criterion({ subject: 'battery_level', operator: '<', triggerValue: 20, resetValue: 35 })], + { battery_level: 35 } + ); + + expect(highThreshold.canReset).toBe(true); + expect(lowThreshold.canReset).toBe(true); + }); +}); + +function criterion(overrides: Partial): RuleTemplateCriterionInput { + return { + subject: overrides.subject ?? 'temperature_c', + operator: overrides.operator ?? '>', + triggerValue: overrides.triggerValue ?? 30, + resetValue: overrides.resetValue ?? 25 + }; +} diff --git a/src/lib/rules-new/rule-template-evaluator.ts b/src/lib/rules-new/rule-template-evaluator.ts new file mode 100644 index 00000000..017afb06 --- /dev/null +++ b/src/lib/rules-new/rule-template-evaluator.ts @@ -0,0 +1,157 @@ +import type { RuleTemplateCriterionInput } from './rule-template.types'; + +type NormalizedOperator = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'neq'; + +export interface RuleTemplateCriterionTest { + subject: string; + operator: string; + triggerValue: number; + resetValue: number; + actualValue: unknown; + matched: boolean; + resetMatched: boolean; + reason?: string; + resetReason?: string; +} + +export interface RuleTemplateTestResult { + matches: boolean; + canReset: boolean; + criteria: RuleTemplateCriterionTest[]; +} + +export function evaluateRuleTemplateCriteria( + criteria: RuleTemplateCriterionInput[], + decodedPayload: Record +): RuleTemplateTestResult { + const results = criteria.map((criterion) => evaluateCriterion(criterion, decodedPayload)); + + return { + matches: results.length > 0 && results.every((criterion) => criterion.matched), + canReset: results.length > 0 && results.every((criterion) => criterion.resetMatched), + criteria: results + }; +} + +function evaluateCriterion( + criterion: RuleTemplateCriterionInput, + decodedPayload: Record +): RuleTemplateCriterionTest { + const actualValue = decodedPayload[criterion.subject]; + const operator = normalizeOperator(criterion.operator); + + if (!operator) { + return { + subject: criterion.subject, + operator: criterion.operator, + triggerValue: criterion.triggerValue, + resetValue: criterion.resetValue, + actualValue, + matched: false, + resetMatched: false, + reason: 'unsupported_operator', + resetReason: 'unsupported_operator' + }; + } + + const actual = toComparableNumber(actualValue); + if (actual === null) { + return { + subject: criterion.subject, + operator: criterion.operator, + triggerValue: criterion.triggerValue, + resetValue: criterion.resetValue, + actualValue, + matched: false, + resetMatched: false, + reason: 'non_comparable_value', + resetReason: 'non_comparable_value' + }; + } + + const matched = compare(actual, operator, criterion.triggerValue); + const resetMatched = compareReset(actual, operator, criterion.resetValue); + + return { + subject: criterion.subject, + operator: criterion.operator, + triggerValue: criterion.triggerValue, + resetValue: criterion.resetValue, + actualValue, + matched, + resetMatched, + resetReason: resetMatched ? undefined : 'reset_not_reached' + }; +} + +function compare(actual: number, operator: NormalizedOperator, expected: number): boolean { + switch (operator) { + case 'gt': + return actual > expected; + case 'gte': + return actual >= expected; + case 'lt': + return actual < expected; + case 'lte': + return actual <= expected; + case 'eq': + return actual === expected; + case 'neq': + return actual !== expected; + } +} + +function compareReset(actual: number, operator: NormalizedOperator, resetValue: number): boolean { + switch (operator) { + case 'gt': + case 'gte': + return actual <= resetValue; + case 'lt': + case 'lte': + return actual >= resetValue; + case 'eq': + case 'neq': + return actual === resetValue; + } +} + +function normalizeOperator(operator: string): NormalizedOperator | null { + switch (operator.trim().toLowerCase()) { + case '>': + case 'gt': + return 'gt'; + case '>=': + case 'gte': + return 'gte'; + case '<': + case 'lt': + return 'lt'; + case '<=': + case 'lte': + return 'lte'; + case '=': + case '==': + case 'eq': + return 'eq'; + case '!=': + case 'neq': + return 'neq'; + default: + return null; + } +} + +function toComparableNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'boolean') return value ? 1 : 0; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} diff --git a/src/lib/rules-new/rule-template.types.ts b/src/lib/rules-new/rule-template.types.ts new file mode 100644 index 00000000..a9cf2977 --- /dev/null +++ b/src/lib/rules-new/rule-template.types.ts @@ -0,0 +1,79 @@ +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export interface RuleTemplateCriterionDto { + id: number; + templateId: number; + subject: string; + operator: string; + triggerValue: number; + resetValue: number; + createdAt: string | null; +} + +export interface RuleTemplateActionDto { + id: number; + templateId: number; + actionType: string; + config: Json; + createdAt: string | null; +} + +export interface RuleTemplateStateDto { + id: number; + devEui: string; + templateId: number; + isTriggered: boolean; + lastTriggeredAt: string | null; + lastResetAt: string | null; +} + +export interface RuleTemplateAssignmentDto { + id: number; + devEui: string; + templateId: number; + isActive: boolean; + createdAt: string | null; + deviceName: string | null; + permissionLevel: number | null; + state: RuleTemplateStateDto | null; +} + +export interface RuleTemplateDto { + id: number; + name: string; + description: string | null; + deviceTypeId: number | null; + isActive: boolean; + createdAt: string | null; + assignments: RuleTemplateAssignmentDto[]; + criteria: RuleTemplateCriterionDto[]; + actions: RuleTemplateActionDto[]; +} + +export interface RuleTemplateCriterionInput { + id?: number | null; + subject: string; + operator: string; + triggerValue: number; + resetValue: number; +} + +export interface RuleTemplateActionInput { + id?: number | null; + actionType: string; + config: Json; +} + +export interface RuleTemplateSaveRequest { + name: string; + description?: string | null; + deviceTypeId?: number | null; + isActive?: boolean; + devEuis: string[]; + criteria: RuleTemplateCriterionInput[]; + actions: RuleTemplateActionInput[]; +} + +export interface RuleTemplateListQuery { + search?: string; +} diff --git a/src/lib/server/rules-new/route-helpers.ts b/src/lib/server/rules-new/route-helpers.ts new file mode 100644 index 00000000..cb9e110e --- /dev/null +++ b/src/lib/server/rules-new/route-helpers.ts @@ -0,0 +1,48 @@ +import { m } from '$lib/paraglide/messages.js'; +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { RuleTemplateRepositoryError } from './rule-template.repository'; + +export interface RuleTemplateRequestContext { + authToken: string; + userId: string; + fetchFn: typeof fetch; +} + +export function requireRuleTemplateRequestContext( + event: RequestEvent +): RuleTemplateRequestContext | Response { + const authToken = event.locals.jwtString ?? null; + const userId = event.locals.jwt?.sub ?? null; + + if (!authToken || !userId) { + return json({ message: m.auth_not_authenticated() }, { status: 401 }); + } + + return { + authToken, + userId, + fetchFn: event.fetch + }; +} + +export function parseRuleTemplateId(rawId: string | undefined): number | Response { + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) { + return json({ message: m.rules_new_invalid_template_id() }, { status: 400 }); + } + + return id; +} + +export function repositoryErrorResponse(error: unknown, fallback: string): Response { + if (error instanceof RuleTemplateRepositoryError) { + if (error.status >= 500) { + console.error(fallback, error); + } + return json({ message: fallback }, { status: error.status }); + } + + console.error(fallback, error); + return json({ message: fallback }, { status: 500 }); +} diff --git a/src/lib/server/rules-new/rule-template.repository.ts b/src/lib/server/rules-new/rule-template.repository.ts new file mode 100644 index 00000000..b8fb7775 --- /dev/null +++ b/src/lib/server/rules-new/rule-template.repository.ts @@ -0,0 +1,917 @@ +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { ApiService } from '$lib/api/api.service'; +import type { DeviceDto } from '$lib/api/api.dtos'; +import type { + Json, + RuleTemplateActionDto, + RuleTemplateActionInput, + RuleTemplateAssignmentDto, + RuleTemplateCriterionDto, + RuleTemplateCriterionInput, + RuleTemplateDto, + RuleTemplateListQuery, + RuleTemplateSaveRequest, + RuleTemplateStateDto +} from '$lib/rules-new/rule-template.types'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + +type FetchLike = typeof fetch; + +interface RuleTemplatesDatabase { + public: { + Tables: { + cw_device_rule_assignments: { + Row: { + created_at: string | null; + dev_eui: string; + id: number; + is_active: boolean | null; + template_id: number; + }; + Insert: { + created_at?: string | null; + dev_eui: string; + id?: number; + is_active?: boolean | null; + template_id: number; + }; + Update: { + created_at?: string | null; + dev_eui?: string; + id?: number; + is_active?: boolean | null; + template_id?: number; + }; + Relationships: []; + }; + cw_rule_templates: { + Row: { + created_at: string | null; + description: string | null; + device_type_id: number | null; + id: number; + is_active: boolean | null; + name: string; + }; + Insert: { + created_at?: string | null; + description?: string | null; + device_type_id?: number | null; + id?: number; + is_active?: boolean | null; + name: string; + }; + Update: { + created_at?: string | null; + description?: string | null; + device_type_id?: number | null; + id?: number; + is_active?: boolean | null; + name?: string; + }; + Relationships: []; + }; + cw_rule_template_criteria: { + Row: { + created_at: string | null; + id: number; + operator: string; + reset_value: number; + subject: string; + template_id: number; + trigger_value: number; + }; + Insert: { + created_at?: string | null; + id?: number; + operator: string; + reset_value: number; + subject: string; + template_id: number; + trigger_value: number; + }; + Update: { + created_at?: string | null; + id?: number; + operator?: string; + reset_value?: number; + subject?: string; + template_id?: number; + trigger_value?: number; + }; + Relationships: []; + }; + cw_rule_template_actions: { + Row: { + action_type: string; + config: Json; + created_at: string | null; + id: number; + template_id: number; + }; + Insert: { + action_type: string; + config: Json; + created_at?: string | null; + id?: number; + template_id: number; + }; + Update: { + action_type?: string; + config?: Json; + created_at?: string | null; + id?: number; + template_id?: number; + }; + Relationships: []; + }; + cw_rule_state: { + Row: { + dev_eui: string; + id: number; + is_triggered: boolean; + last_reset_at: string | null; + last_triggered_at: string | null; + template_id: number; + }; + Insert: { + dev_eui: string; + id?: number; + is_triggered?: boolean; + last_reset_at?: string | null; + last_triggered_at?: string | null; + template_id: number; + }; + Update: { + dev_eui?: string; + id?: number; + is_triggered?: boolean; + last_reset_at?: string | null; + last_triggered_at?: string | null; + template_id?: number; + }; + Relationships: []; + }; + }; + Views: Record; + Functions: Record; + Enums: Record; + CompositeTypes: Record; + }; +} + +type RuleTemplateRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_templates']['Row']; +type RuleAssignmentRow = + RuleTemplatesDatabase['public']['Tables']['cw_device_rule_assignments']['Row']; +type RuleCriterionRow = + RuleTemplatesDatabase['public']['Tables']['cw_rule_template_criteria']['Row']; +type RuleActionRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_template_actions']['Row']; +type RuleStateRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_state']['Row']; + +interface RuleTemplateRepositoryContext { + authToken: string; + userId: string; + fetchFn: FetchLike; +} + +interface ManagedDevice { + devEui: string; + name: string | null; + permissionLevel: number | null; + canView: boolean; + canManage: boolean; + type: number | null; +} + +interface NormalizedRuleTemplateSaveRequest { + name: string; + description: string | null; + deviceTypeId: number | null; + isActive: boolean; + devEuis: string[]; + criteria: RuleTemplateCriterionInput[]; + actions: RuleTemplateActionInput[]; +} + +export class RuleTemplateRepositoryError extends Error { + public readonly status: number; + + public constructor(status: number, message: string) { + super(message); + this.name = 'RuleTemplateRepositoryError'; + this.status = status; + } +} + +export async function listRuleTemplatesForUser( + context: RuleTemplateRepositoryContext, + query: RuleTemplateListQuery = {} +): Promise { + const devices = await listManagedDevices(context); + const viewableDevices = devices.filter((device) => device.canView); + if (viewableDevices.length === 0) return []; + + const client = createRuleTemplateClient(context.authToken); + const assignmentsResult = await client + .from('cw_device_rule_assignments') + .select('created_at, dev_eui, id, is_active, template_id') + .in( + 'dev_eui', + viewableDevices.map((device) => device.devEui) + ); + + if (assignmentsResult.error) { + throwSupabaseError('Failed to load rule assignments', assignmentsResult.error); + } + + const assignments = assignmentsResult.data ?? []; + const templateIds = uniqueIds(assignments.map((assignment) => assignment.template_id)); + if (templateIds.length === 0) return []; + + const templates = await loadTemplatesByIds(client, templateIds); + const criteria = await loadCriteriaByTemplateIds(client, templateIds); + const actions = await loadActionsByTemplateIds(client, templateIds); + const states = await loadStates( + client, + templateIds, + assignments.map((assignment) => assignment.dev_eui) + ); + + const rules = buildRuleTemplates({ + templates, + assignments, + criteria, + actions, + states, + devices + }); + + const search = query.search?.trim().toLowerCase(); + if (!search) return rules; + + return rules.filter((rule) => matchesSearch(rule, search)); +} + +export async function getRuleTemplateForUser( + context: RuleTemplateRepositoryContext, + templateId: number +): Promise { + const devices = await listManagedDevices(context); + const viewableDevices = devices.filter((device) => device.canView); + if (viewableDevices.length === 0) { + throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); + } + + const client = createRuleTemplateClient(context.authToken); + const [template, assignmentsResult] = await Promise.all([ + loadTemplateById(client, templateId), + client + .from('cw_device_rule_assignments') + .select('created_at, dev_eui, id, is_active, template_id') + .eq('template_id', templateId) + .in( + 'dev_eui', + viewableDevices.map((device) => device.devEui) + ) + ]); + + if (assignmentsResult.error) { + throwSupabaseError('Failed to load rule assignments', assignmentsResult.error); + } + + const assignments = assignmentsResult.data ?? []; + if (assignments.length === 0) { + throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); + } + + const [criteria, actions, states] = await Promise.all([ + loadCriteriaByTemplateIds(client, [templateId]), + loadActionsByTemplateIds(client, [templateId]), + loadStates( + client, + [templateId], + assignments.map((assignment) => assignment.dev_eui) + ) + ]); + + const [rule] = buildRuleTemplates({ + templates: [template], + assignments, + criteria, + actions, + states, + devices + }); + + if (!rule) { + throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); + } + + return rule; +} + +export async function createRuleTemplateForUser( + context: RuleTemplateRepositoryContext, + input: unknown +): Promise { + const payload = normalizeRuleTemplateSaveRequest(input); + const devices = await listManagedDevices(context); + assertDevicesCanBeManaged(devices, payload.devEuis); + + const client = createRuleTemplateClient(context.authToken); + const insertResult = await client + .from('cw_rule_templates') + .insert({ + name: payload.name, + description: payload.description, + device_type_id: payload.deviceTypeId, + is_active: payload.isActive + }) + .select('created_at, description, device_type_id, id, is_active, name') + .single(); + + if (insertResult.error) { + throwSupabaseError('Failed to create rule template', insertResult.error); + } + + const template = insertResult.data; + try { + await replaceTemplateChildren(client, template.id, payload); + } catch (error) { + await deleteTemplateBestEffort(client, template.id); + throw error; + } + + return getRuleTemplateForUser(context, template.id); +} + +export async function updateRuleTemplateForUser( + context: RuleTemplateRepositoryContext, + templateId: number, + input: unknown +): Promise { + const payload = normalizeRuleTemplateSaveRequest(input); + const existing = await getRuleTemplateForUser(context, templateId); + const devices = await listManagedDevices(context); + + assertDevicesCanBeManaged( + devices, + uniqueIds([...existing.assignments.map((assignment) => assignment.devEui), ...payload.devEuis]) + ); + + const client = createRuleTemplateClient(context.authToken); + const updateResult = await client + .from('cw_rule_templates') + .update({ + name: payload.name, + description: payload.description, + device_type_id: payload.deviceTypeId, + is_active: payload.isActive + }) + .eq('id', templateId); + + if (updateResult.error) { + throwSupabaseError('Failed to update rule template', updateResult.error); + } + + await replaceTemplateChildren(client, templateId, payload); + await deleteTemplateState(client, templateId); + + return getRuleTemplateForUser(context, templateId); +} + +export async function deleteRuleTemplateForUser( + context: RuleTemplateRepositoryContext, + templateId: number +): Promise<{ id: number }> { + const existing = await getRuleTemplateForUser(context, templateId); + const devices = await listManagedDevices(context); + assertDevicesCanBeManaged( + devices, + existing.assignments.map((assignment) => assignment.devEui) + ); + + const client = createRuleTemplateClient(context.authToken); + await deleteTemplateState(client, templateId); + await deleteTemplateChildren(client, templateId); + + const deleteResult = await client.from('cw_rule_templates').delete().eq('id', templateId); + if (deleteResult.error) { + throwSupabaseError('Failed to delete rule template', deleteResult.error); + } + + return { id: templateId }; +} + +function createRuleTemplateClient(authToken: string): SupabaseClient { + if (!PUBLIC_SUPABASE_URL || !PUBLIC_SUPABASE_ANON_KEY) { + throw new RuleTemplateRepositoryError(500, 'Supabase is not configured.'); + } + + return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false + }, + global: { + headers: { + Authorization: `Bearer ${authToken}` + } + } + }); +} + +async function listManagedDevices( + context: RuleTemplateRepositoryContext +): Promise { + const api = new ApiService({ fetchFn: context.fetchFn, authToken: context.authToken }); + const devices = await api.getAllDevices().catch((error) => { + console.error('Failed to load devices for rule template access checks:', error); + return []; + }); + + return devices.map((device) => normalizeManagedDevice(device, context.userId)); +} + +function normalizeManagedDevice(device: DeviceDto, userId: string): ManagedDevice { + const permissionLevel = readDevicePermissionLevel(device, userId); + const devEui = typeof device.dev_eui === 'string' ? device.dev_eui : ''; + const name = typeof device.name === 'string' && device.name.trim() ? device.name : null; + const type = typeof device.type === 'number' && Number.isFinite(device.type) ? device.type : null; + + return { + devEui, + name, + type, + permissionLevel, + canView: devEui.length > 0 && (permissionLevel == null || permissionLevel <= 3), + canManage: devEui.length > 0 && (permissionLevel == null || permissionLevel <= 2) + }; +} + +function readDevicePermissionLevel(device: DeviceDto, userId: string): number | null { + const directPermissionLevel = readFiniteNumber( + (device as Record).permission_level + ); + if (directPermissionLevel !== null) return directPermissionLevel; + + if (device.user_id === userId) return 1; + + const owners = Array.isArray(device.cw_device_owners) ? device.cw_device_owners : []; + const owner = owners.find((entry) => entry.user_id === userId); + const ownerPermissionLevel = readFiniteNumber(owner?.permission_level); + + return ownerPermissionLevel; +} + +function assertDevicesCanBeManaged(devices: ManagedDevice[], devEuis: string[]): void { + const manageableDeviceIds = new Set( + devices.filter((device) => device.canManage).map((device) => device.devEui) + ); + const missingDevice = devEuis.find((devEui) => !manageableDeviceIds.has(devEui)); + + if (missingDevice) { + throw new RuleTemplateRepositoryError( + 403, + 'You do not have permission to manage one or more selected devices.' + ); + } +} + +async function loadTemplateById( + client: SupabaseClient, + templateId: number +): Promise { + const result = await client + .from('cw_rule_templates') + .select('created_at, description, device_type_id, id, is_active, name') + .eq('id', templateId) + .maybeSingle(); + + if (result.error) { + throwSupabaseError('Failed to load rule template', result.error); + } + + if (!result.data) { + throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); + } + + return result.data; +} + +async function loadTemplatesByIds( + client: SupabaseClient, + templateIds: number[] +): Promise { + const result = await client + .from('cw_rule_templates') + .select('created_at, description, device_type_id, id, is_active, name') + .in('id', templateIds); + + if (result.error) { + throwSupabaseError('Failed to load rule templates', result.error); + } + + return result.data ?? []; +} + +async function loadCriteriaByTemplateIds( + client: SupabaseClient, + templateIds: number[] +): Promise { + const result = await client + .from('cw_rule_template_criteria') + .select('created_at, id, operator, reset_value, subject, template_id, trigger_value') + .in('template_id', templateIds); + + if (result.error) { + throwSupabaseError('Failed to load rule criteria', result.error); + } + + return result.data ?? []; +} + +async function loadActionsByTemplateIds( + client: SupabaseClient, + templateIds: number[] +): Promise { + const result = await client + .from('cw_rule_template_actions') + .select('action_type, config, created_at, id, template_id') + .in('template_id', templateIds); + + if (result.error) { + throwSupabaseError('Failed to load rule actions', result.error); + } + + return result.data ?? []; +} + +async function loadStates( + client: SupabaseClient, + templateIds: number[], + devEuis: string[] +): Promise { + const uniqueDevEuis = uniqueIds(devEuis); + if (templateIds.length === 0 || uniqueDevEuis.length === 0) return []; + + const result = await client + .from('cw_rule_state') + .select('dev_eui, id, is_triggered, last_reset_at, last_triggered_at, template_id') + .in('template_id', templateIds) + .in('dev_eui', uniqueDevEuis); + + if (result.error) { + throwSupabaseError('Failed to load rule state', result.error); + } + + return result.data ?? []; +} + +async function replaceTemplateChildren( + client: SupabaseClient, + templateId: number, + payload: NormalizedRuleTemplateSaveRequest +): Promise { + await deleteTemplateChildren(client, templateId); + + const assignments = payload.devEuis.map((devEui) => ({ + dev_eui: devEui, + template_id: templateId, + is_active: true + })); + const criteria = payload.criteria.map((criterion) => ({ + template_id: templateId, + subject: criterion.subject, + operator: criterion.operator, + trigger_value: criterion.triggerValue, + reset_value: criterion.resetValue + })); + const actions = payload.actions.map((action) => ({ + template_id: templateId, + action_type: action.actionType, + config: action.config + })); + + const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ + client.from('cw_device_rule_assignments').insert(assignments), + client.from('cw_rule_template_criteria').insert(criteria), + client.from('cw_rule_template_actions').insert(actions) + ]); + + if (assignmentsResult.error) { + throwSupabaseError('Failed to save rule assignments', assignmentsResult.error); + } + if (criteriaResult.error) { + throwSupabaseError('Failed to save rule criteria', criteriaResult.error); + } + if (actionsResult.error) { + throwSupabaseError('Failed to save rule actions', actionsResult.error); + } +} + +async function deleteTemplateChildren( + client: SupabaseClient, + templateId: number +): Promise { + const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ + client.from('cw_device_rule_assignments').delete().eq('template_id', templateId), + client.from('cw_rule_template_criteria').delete().eq('template_id', templateId), + client.from('cw_rule_template_actions').delete().eq('template_id', templateId) + ]); + + if (assignmentsResult.error) { + throwSupabaseError('Failed to remove rule assignments', assignmentsResult.error); + } + if (criteriaResult.error) { + throwSupabaseError('Failed to remove rule criteria', criteriaResult.error); + } + if (actionsResult.error) { + throwSupabaseError('Failed to remove rule actions', actionsResult.error); + } +} + +async function deleteTemplateState( + client: SupabaseClient, + templateId: number +): Promise { + const result = await client.from('cw_rule_state').delete().eq('template_id', templateId); + if (result.error) { + throwSupabaseError('Failed to reset rule state', result.error); + } +} + +async function deleteTemplateBestEffort( + client: SupabaseClient, + templateId: number +): Promise { + try { + await deleteTemplateChildren(client, templateId); + await client.from('cw_rule_templates').delete().eq('id', templateId); + } catch (error) { + console.error('Failed to clean up partially-created rule template:', { templateId, error }); + } +} + +function buildRuleTemplates({ + templates, + assignments, + criteria, + actions, + states, + devices +}: { + templates: RuleTemplateRow[]; + assignments: RuleAssignmentRow[]; + criteria: RuleCriterionRow[]; + actions: RuleActionRow[]; + states: RuleStateRow[]; + devices: ManagedDevice[]; +}): RuleTemplateDto[] { + const devicesById = new Map(devices.map((device) => [device.devEui, device])); + const assignmentsByTemplateId = groupBy(assignments, (assignment) => assignment.template_id); + const criteriaByTemplateId = groupBy(criteria, (criterion) => criterion.template_id); + const actionsByTemplateId = groupBy(actions, (action) => action.template_id); + const statesByTemplateAndDevice = new Map( + states.map((state) => [`${state.template_id}:${state.dev_eui}`, state]) + ); + + return templates + .map((template): RuleTemplateDto | null => { + const ruleAssignments = assignmentsByTemplateId.get(template.id) ?? []; + if (ruleAssignments.length === 0) return null; + + return { + id: template.id, + name: template.name, + description: template.description, + deviceTypeId: template.device_type_id, + isActive: template.is_active ?? true, + createdAt: template.created_at, + assignments: ruleAssignments.map((assignment): RuleTemplateAssignmentDto => { + const device = devicesById.get(assignment.dev_eui); + const state = statesByTemplateAndDevice.get( + `${assignment.template_id}:${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, + permissionLevel: device?.permissionLevel ?? null, + state: state ? mapState(state) : null + }; + }), + criteria: (criteriaByTemplateId.get(template.id) ?? []).map(mapCriterion), + actions: (actionsByTemplateId.get(template.id) ?? []).map(mapAction) + }; + }) + .filter((rule): rule is RuleTemplateDto => rule !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function mapCriterion(row: RuleCriterionRow): RuleTemplateCriterionDto { + return { + id: row.id, + templateId: row.template_id, + subject: row.subject, + operator: row.operator, + triggerValue: row.trigger_value, + resetValue: row.reset_value, + createdAt: row.created_at + }; +} + +function mapAction(row: RuleActionRow): RuleTemplateActionDto { + return { + id: row.id, + templateId: row.template_id, + actionType: row.action_type, + config: row.config, + createdAt: row.created_at + }; +} + +function mapState(row: RuleStateRow): RuleTemplateStateDto { + return { + id: row.id, + devEui: row.dev_eui, + templateId: row.template_id, + isTriggered: row.is_triggered, + lastTriggeredAt: row.last_triggered_at, + lastResetAt: row.last_reset_at + }; +} + +function normalizeRuleTemplateSaveRequest(input: unknown): NormalizedRuleTemplateSaveRequest { + if (!isRecord(input)) { + throw new RuleTemplateRepositoryError(400, 'Invalid rule template payload.'); + } + + const name = typeof input.name === 'string' ? input.name.trim() : ''; + if (!name) { + throw new RuleTemplateRepositoryError(400, 'Rule name is required.'); + } + + const devEuis = normalizeStringArray(input.devEuis); + if (devEuis.length === 0) { + throw new RuleTemplateRepositoryError(400, 'At least one device is required.'); + } + + const criteria = normalizeCriteria(input.criteria); + if (criteria.length === 0) { + throw new RuleTemplateRepositoryError(400, 'At least one condition is required.'); + } + + const actions = normalizeActions(input.actions); + if (actions.length === 0) { + throw new RuleTemplateRepositoryError(400, 'At least one action is required.'); + } + + return { + name, + description: + typeof input.description === 'string' && input.description.trim() + ? input.description.trim() + : null, + deviceTypeId: readFiniteNumber(input.deviceTypeId), + isActive: typeof input.isActive === 'boolean' ? input.isActive : true, + devEuis, + criteria, + actions + }; +} + +function normalizeCriteria(input: unknown): RuleTemplateCriterionInput[] { + if (!Array.isArray(input)) { + throw new RuleTemplateRepositoryError(400, 'Rule criteria must be an array.'); + } + + return input.map((item, index) => { + if (!isRecord(item)) { + throw new RuleTemplateRepositoryError(400, `Condition ${index + 1} is invalid.`); + } + + const subject = typeof item.subject === 'string' ? item.subject.trim() : ''; + const operator = typeof item.operator === 'string' ? item.operator.trim() : ''; + const triggerValue = readFiniteNumber(item.triggerValue); + const resetValue = readFiniteNumber(item.resetValue); + + if (!subject || !operator || triggerValue === null || resetValue === null) { + throw new RuleTemplateRepositoryError( + 400, + `Condition ${index + 1} must include a field, operator, trigger value, and reset value.` + ); + } + + return { + id: readFiniteNumber(item.id), + subject, + operator, + triggerValue, + resetValue + }; + }); +} + +function normalizeActions(input: unknown): RuleTemplateActionInput[] { + if (!Array.isArray(input)) { + throw new RuleTemplateRepositoryError(400, 'Rule actions must be an array.'); + } + + return input.map((item, index) => { + if (!isRecord(item)) { + throw new RuleTemplateRepositoryError(400, `Action ${index + 1} is invalid.`); + } + + const actionType = typeof item.actionType === 'string' ? item.actionType.trim() : ''; + if (!actionType) { + throw new RuleTemplateRepositoryError(400, `Action ${index + 1} needs a type.`); + } + + const config = item.config; + if (!isJson(config)) { + throw new RuleTemplateRepositoryError(400, `Action ${index + 1} has invalid config.`); + } + + return { + id: readFiniteNumber(item.id), + actionType, + config + }; + }); +} + +function normalizeStringArray(input: unknown): string[] { + if (!Array.isArray(input)) return []; + + return uniqueIds( + input + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) + ); +} + +function matchesSearch(rule: RuleTemplateDto, search: string): boolean { + const deviceText = rule.assignments + .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) + .join(' '); + + return [rule.name, rule.description ?? '', deviceText].join(' ').toLowerCase().includes(search); +} + +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) ?? []; + existing.push(item); + result.set(groupKey, existing); + } + return result; +} + +function uniqueIds(values: T[]): T[] { + return [...new Set(values)]; +} + +function readFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function isJson(value: unknown): value is Json { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return typeof value !== 'number' || Number.isFinite(value); + } + + if (Array.isArray(value)) { + return value.every(isJson); + } + + if (!isRecord(value)) { + return false; + } + + return Object.values(value).every((entry) => entry === undefined || isJson(entry)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function throwSupabaseError(context: string, error: { message: string }): never { + throw new RuleTemplateRepositoryError(500, `${context}: ${error.message}`); +} diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index a82be489..a216ed78 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -56,6 +56,13 @@ icon: { path: RULES_ICON_PATH }, group: m.nav_group_info_management() }, + { + id: 'rules-new', + label: m.nav_rules(), + href: '/rules-new', + icon: { path: RULES_ICON_PATH }, + group: m.nav_group_info_management() + }, { id: 'reports', label: m.nav_reports(), diff --git a/src/routes/api/rules-new/+server.ts b/src/routes/api/rules-new/+server.ts new file mode 100644 index 00000000..940e9601 --- /dev/null +++ b/src/routes/api/rules-new/+server.ts @@ -0,0 +1,36 @@ +import { + createRuleTemplateForUser, + listRuleTemplatesForUser +} from '$lib/server/rules-new/rule-template.repository'; +import { + repositoryErrorResponse, + requireRuleTemplateRequestContext +} from '$lib/server/rules-new/route-helpers'; +import { m } from '$lib/paraglide/messages.js'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const context = requireRuleTemplateRequestContext(event); + if (context instanceof Response) return context; + + try { + const search = event.url.searchParams.get('search') ?? undefined; + return json(await listRuleTemplatesForUser(context, { search })); + } catch (error) { + return repositoryErrorResponse(error, m.rules_new_load_failed()); + } +}; + +export const POST: RequestHandler = async (event) => { + const context = requireRuleTemplateRequestContext(event); + if (context instanceof Response) return context; + + try { + const payload = await event.request.json(); + const rule = await createRuleTemplateForUser(context, payload); + return json(rule, { status: 201 }); + } catch (error) { + return repositoryErrorResponse(error, m.rules_new_save_failed()); + } +}; diff --git a/src/routes/api/rules-new/[id]/+server.ts b/src/routes/api/rules-new/[id]/+server.ts new file mode 100644 index 00000000..f3e054f9 --- /dev/null +++ b/src/routes/api/rules-new/[id]/+server.ts @@ -0,0 +1,56 @@ +import { + deleteRuleTemplateForUser, + getRuleTemplateForUser, + updateRuleTemplateForUser +} from '$lib/server/rules-new/rule-template.repository'; +import { + parseRuleTemplateId, + repositoryErrorResponse, + requireRuleTemplateRequestContext +} from '$lib/server/rules-new/route-helpers'; +import { m } from '$lib/paraglide/messages.js'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const context = requireRuleTemplateRequestContext(event); + if (context instanceof Response) return context; + + const templateId = parseRuleTemplateId(event.params.id); + if (templateId instanceof Response) return templateId; + + try { + return json(await getRuleTemplateForUser(context, templateId)); + } catch (error) { + return repositoryErrorResponse(error, m.rules_new_load_failed()); + } +}; + +export const PATCH: RequestHandler = async (event) => { + const context = requireRuleTemplateRequestContext(event); + if (context instanceof Response) return context; + + const templateId = parseRuleTemplateId(event.params.id); + if (templateId instanceof Response) return templateId; + + try { + const payload = await event.request.json(); + return json(await updateRuleTemplateForUser(context, templateId, payload)); + } catch (error) { + return repositoryErrorResponse(error, m.rules_new_save_failed()); + } +}; + +export const DELETE: RequestHandler = async (event) => { + const context = requireRuleTemplateRequestContext(event); + if (context instanceof Response) return context; + + const templateId = parseRuleTemplateId(event.params.id); + if (templateId instanceof Response) return templateId; + + try { + return json(await deleteRuleTemplateForUser(context, templateId)); + } catch (error) { + return repositoryErrorResponse(error, m.rules_new_delete_failed()); + } +}; diff --git a/src/routes/rules-new/+page.server.ts b/src/routes/rules-new/+page.server.ts new file mode 100644 index 00000000..9b24be95 --- /dev/null +++ b/src/routes/rules-new/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; + +// The table fetches rule templates through the local /api/rules-new endpoint so +// search, paging, and deletes can refresh without duplicating list data in SSR. +export const load: PageServerLoad = async () => { + return {}; +}; diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte new file mode 100644 index 00000000..3e9dfb96 --- /dev/null +++ b/src/routes/rules-new/+page.svelte @@ -0,0 +1,230 @@ + + + + {m.rules_new_page_title()} + + + + goto(resolve('/'))}> + ← {m.action_back_to_dashboard()} + + + + {#key tableKey} + + {#snippet cell( + row: RuleTemplateRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'statusLabel'} + + {:else if col.key === 'triggeredCount'} + 0 ? 'danger' : 'secondary'} + variant="soft" + size="sm" + /> + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet rowActions(row: RuleTemplateRow)} +
+ goto(resolve('/rules-new/edit/[id]', { id: String(row.id) }))} + > + + + +
+ {/snippet} + + {#snippet toolbarActions()} + goto(resolve('/rules-new/create'))}> + + {m.rules_new_create_template()} + + {/snippet} +
+ {/key} +
+
+ + diff --git a/src/routes/rules-new/DeleteRuleTemplateDialog.svelte b/src/routes/rules-new/DeleteRuleTemplateDialog.svelte new file mode 100644 index 00000000..2674b91e --- /dev/null +++ b/src/routes/rules-new/DeleteRuleTemplateDialog.svelte @@ -0,0 +1,87 @@ + + + (open = true)}> + + + + +

{m.rules_delete_confirmation({ name: ruleName })}

+ + {#snippet actions()} +
+ (open = false)}> + {m.action_cancel()} + + + {m.action_delete()} + +
+ {/snippet} +
+ + diff --git a/src/routes/rules-new/RuleTemplateForm.svelte b/src/routes/rules-new/RuleTemplateForm.svelte new file mode 100644 index 00000000..b1e3e6ad --- /dev/null +++ b/src/routes/rules-new/RuleTemplateForm.svelte @@ -0,0 +1,675 @@ + + + + + + + + + + + + + {#if deviceOptionsBase.length === 0} + +

{m.rules_no_devices_available()}

+
+ {:else} + {#each assignments as assignment, index (assignment.localId)} +
+
+ {m.rules_new_assignment_number({ count: String(index + 1) })} + {#if assignments.length > 1} + removeAssignment(assignment.localId)} + > + {m.action_remove()} + + {/if} +
+ +
+ {/each} + + {#if hasDuplicateDeviceAssignments} + +

{m.rules_new_duplicate_devices()}

+
+ {/if} + + = deviceOptionsBase.length} + > + {m.rules_new_add_device_assignment()} + + {/if} +
+
+ + + + {#each criteria as criterion, index (criterion.localId)} +
+
+ {m.rules_condition_number({ count: String(index + 1) })} + {#if criteria.length > 1} + removeCriterion(criterion.localId)}> + {m.action_remove()} + + {/if} +
+ +
+ + + + +
+
+ {/each} + + + {m.rules_add_another_condition()} + +
+
+ + + + {#each actions as action, index (action.localId)} +
+
+ {m.rules_new_action_number({ count: String(index + 1) })} + {#if actions.length > 1} + removeAction(action.localId)}> + {m.action_remove()} + + {/if} +
+ +
+ + + +
+
+ {/each} + + + {m.rules_new_add_action()} + +
+
+ + + + + + {#if isFormValid} + +
+
{m.common_name()}:
+
{ruleName}
+
{m.rules_new_status()}:
+
{isActive ? m.rules_new_active() : m.rules_new_inactive()}
+
{m.rules_new_assigned_devices()}:
+
+
+ {#each assignmentPreview as assignment (assignment)} + + {/each} +
+
+
{m.rules_conditions()}:
+
+
+ {#each criteriaPreview as item, index (item + index)} + + {/each} +
+
+
{m.rules_new_actions()}:
+
+
+ {#each actionPreview as action (action)} + + {/each} +
+
+
+
+ {:else} + +

{m.rules_complete_required_fields()}

+
+ {/if} + + + + + goto(resolve('/rules-new'))} disabled={submitting}> + {m.action_cancel()} + + + {submitting + ? m.action_saving() + : mode === 'edit' + ? m.action_save_changes() + : m.rules_create_rule()} + + +
+
+ + diff --git a/src/routes/rules-new/RuleTemplateTest.svelte b/src/routes/rules-new/RuleTemplateTest.svelte new file mode 100644 index 00000000..cd6c929d --- /dev/null +++ b/src/routes/rules-new/RuleTemplateTest.svelte @@ -0,0 +1,239 @@ + + + + + + + + {m.rules_new_test_button()} + + + {#if testError} + +

{testError}

+
+ {/if} + + {#if testResult} + +
+ {#each testResult.criteria as criterion, index (criterion.subject + index)} +
+
+

+ {criterion.subject} + {criterion.operator} + {criterion.triggerValue} +

+

+ {m.rules_new_test_actual_value()}: + {formatActualValue(criterion.actualValue)} +

+ {#if criterion.reason || criterion.resetReason} +

{reasonLabel(criterion.reason ?? criterion.resetReason)}

+ {/if} +
+
+ + +
+
+ {/each} +
+
+ {/if} +
+
+ + diff --git a/src/routes/rules-new/create/+page.server.ts b/src/routes/rules-new/create/+page.server.ts new file mode 100644 index 00000000..9ca83295 --- /dev/null +++ b/src/routes/rules-new/create/+page.server.ts @@ -0,0 +1,16 @@ +import { ApiService } from '$lib/api/api.service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, url }) => { + const authToken = locals.jwtString ?? null; + const devEui = url.searchParams.get('dev_eui') ?? null; + + if (!authToken) { + return { devices: [], devEui }; + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + const devices = await api.getAllDevices().catch(() => []); + + return { devices, devEui }; +}; diff --git a/src/routes/rules-new/create/+page.svelte b/src/routes/rules-new/create/+page.svelte new file mode 100644 index 00000000..0e0d624b --- /dev/null +++ b/src/routes/rules-new/create/+page.svelte @@ -0,0 +1,37 @@ + + + + {m.rules_new_create_page_title()} + + + + goto(resolve('/rules-new'))}> + ← {m.action_back()} + + +
+

{m.rules_new_create_template()}

+
+ + +
+ + diff --git a/src/routes/rules-new/edit/[id]/+page.server.ts b/src/routes/rules-new/edit/[id]/+page.server.ts new file mode 100644 index 00000000..384296a3 --- /dev/null +++ b/src/routes/rules-new/edit/[id]/+page.server.ts @@ -0,0 +1,39 @@ +import { ApiService } from '$lib/api/api.service'; +import { + getRuleTemplateForUser, + RuleTemplateRepositoryError +} from '$lib/server/rules-new/rule-template.repository'; +import { m } from '$lib/paraglide/messages.js'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const userId = locals.jwt?.sub ?? null; + const templateId = Number(params.id); + + if (!authToken || !userId) { + error(401, m.error_unauthorized_title()); + } + + if (!Number.isInteger(templateId) || templateId <= 0) { + error(400, m.rules_new_invalid_template_id()); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + let template; + try { + template = await getRuleTemplateForUser({ authToken, userId, fetchFn: fetch }, templateId); + } catch (loadError) { + if (loadError instanceof RuleTemplateRepositoryError && loadError.status === 404) { + error(404, m.rules_new_rule_template_not_found()); + } + + console.error('Failed to load rule template:', loadError); + error(500, m.rules_new_load_failed()); + } + + const devices = await api.getAllDevices().catch(() => []); + + return { template, devices }; +}; diff --git a/src/routes/rules-new/edit/[id]/+page.svelte b/src/routes/rules-new/edit/[id]/+page.svelte new file mode 100644 index 00000000..9c022938 --- /dev/null +++ b/src/routes/rules-new/edit/[id]/+page.svelte @@ -0,0 +1,52 @@ + + + + {m.rules_new_edit_page_title()} + + + + goto(resolve('/rules-new'))}> + ← {m.action_back()} + + +
+

{m.rules_new_edit_template()}

+ +
+ + +
+ + From 57276d03322ee4b416def6e8507bda04f8f3965f Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sat, 2 May 2026 01:42:52 +0900 Subject: [PATCH 3/7] lavin prep --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/lib/api/api-error.ts | 49 +- src/lib/api/api.dtos.ts | 90 ++ src/lib/api/api.service.spec.ts | 165 +++- src/lib/api/api.service.ts | 64 ++ .../dashboard/DashboardDeviceCards.svelte | 65 +- .../DashboardDeviceCards.svelte.spec.ts | 98 +- .../dashboard/dashboard-device-data.spec.ts | 2 - .../dashboard/dashboard-device-data.ts | 60 +- src/lib/components/dashboard/device-cards.ts | 65 +- src/lib/components/layout/AppPage.svelte | 2 +- src/lib/interfaces/device.interface.ts | 1 + src/lib/rules-new/rule-template-client.ts | 164 ---- src/lib/rules-new/rule-template.types.ts | 92 +- src/lib/server/rules-new/route-helpers.ts | 48 - .../rules-new/rule-template.repository.ts | 917 ------------------ src/routes/+layout.server.ts | 88 +- src/routes/+layout.svelte | 74 +- src/routes/+page.server.ts | 204 +--- src/routes/+page.svelte | 129 +-- src/routes/OverviewDrawer.svelte | 16 +- src/routes/api/rules-new/+server.ts | 36 - src/routes/api/rules-new/[id]/+server.ts | 56 -- src/routes/auth/create-account/+page.svelte | 257 +---- .../CreateAccountConsentFields.svelte | 103 ++ .../CreateAccountPasswordFields.svelte | 138 +++ src/routes/auth/forgot-password/+page.svelte | 221 +---- src/routes/auth/update-password/+page.svelte | 2 +- src/routes/gateways/+page.svelte | 4 +- .../devices/[dev_eui]/+page.svelte | 384 ++------ .../[dev_eui]/DeviceDashboardHeader.svelte | 188 ++++ .../DeviceOwnerPermissionsCard.svelte | 187 ++++ .../[dev_eui]/SensorCertificatesCard.svelte | 124 +++ .../[dev_eui]/ViewNoteHistoryDialog.svelte | 14 +- .../devices/[dev_eui]/csvExport.ts | 27 +- .../devices/[dev_eui]/device-detail.ts | 167 ++++ .../[dev_eui]/settings/+page.server.ts | 253 +---- .../devices/[dev_eui]/settings/+page.svelte | 287 +----- .../settings/device-settings.server.ts | 235 +++++ .../[location_id]/settings/+page.server.ts | 402 +------- .../location-settings-actions.server.ts | 415 ++++++++ .../reports/[report_id]/edit/+page.server.ts | 434 +-------- .../reports/[report_id]/edit/+page.svelte | 553 +---------- .../[report_id]/edit/report-action.server.ts | 421 ++++++++ .../reports/[report_id]/edit/report-form.ts | 542 +++++++++++ src/routes/rules-new/+page.server.ts | 7 - src/routes/rules-new/+page.svelte | 18 +- .../rules-new/DeleteRuleTemplateDialog.svelte | 13 +- src/routes/rules-new/RuleTemplateForm.svelte | 364 +++---- src/routes/rules-new/RuleTemplateTest.svelte | 4 +- src/routes/rules-new/create/+page.server.ts | 9 +- src/routes/rules-new/create/+page.svelte | 11 +- .../rules-new/edit/[id]/+page.server.ts | 17 +- src/routes/rules-new/edit/[id]/+page.svelte | 11 +- .../rule-template-alert-points.spec.ts | 113 +++ .../rules-new/rule-template-alert-points.ts | 261 +++++ src/routes/rules/RuleForm.svelte | 442 +++++++++ src/routes/rules/create/+page.svelte | 392 +------- src/routes/rules/edit/[id]/+page.svelte | 394 +------- 60 files changed, 4531 insertions(+), 5380 deletions(-) delete mode 100644 src/lib/rules-new/rule-template-client.ts delete mode 100644 src/lib/server/rules-new/route-helpers.ts delete mode 100644 src/lib/server/rules-new/rule-template.repository.ts delete mode 100644 src/routes/api/rules-new/+server.ts delete mode 100644 src/routes/api/rules-new/[id]/+server.ts create mode 100644 src/routes/auth/create-account/CreateAccountConsentFields.svelte create mode 100644 src/routes/auth/create-account/CreateAccountPasswordFields.svelte create mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte create mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/DeviceOwnerPermissionsCard.svelte create mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte create mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts create mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts create mode 100644 src/routes/locations/[location_id]/settings/location-settings-actions.server.ts create mode 100644 src/routes/reports/[report_id]/edit/report-action.server.ts create mode 100644 src/routes/reports/[report_id]/edit/report-form.ts delete mode 100644 src/routes/rules-new/+page.server.ts create mode 100644 src/routes/rules-new/rule-template-alert-points.spec.ts create mode 100644 src/routes/rules-new/rule-template-alert-points.ts create mode 100644 src/routes/rules/RuleForm.svelte diff --git a/package.json b/package.json index 24d1462d..3d050c48 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.86", + "@cropwatchdevelopment/cwui": "0.1.88", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae01176d..fea5a797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.86 - version: 0.1.86(svelte@5.53.0) + specifier: 0.1.88 + version: 0.1.88(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.86': - resolution: {integrity: sha512-0OepkYq8ThBXXrEFvCsenhSr4wT7NxLmoM/gtCC6UtbotI9eNTn4QU/MeeNE5BXQfpoQRKYsCDop6qixT7uwNQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.86/96ca0fa5fe347545897d6ca6ae7cd7a1a2c4369e} + '@cropwatchdevelopment/cwui@0.1.88': + resolution: {integrity: sha512-GHWImrFRt4lS4SRp2M4+B4yuNHqc3EvnYeuUaEY1bzb05B7tCpv2SWTEG2MapoPn7UCNbh/MMPTotoLwm2mqIA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.88/298733ca9370b797d09aad327f0bd24c91ce1a73} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.86(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.88(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/api/api-error.ts b/src/lib/api/api-error.ts index 9c3a4cf6..0507e6ee 100644 --- a/src/lib/api/api-error.ts +++ b/src/lib/api/api-error.ts @@ -7,45 +7,46 @@ * - `{ payload: { message: ... } }` (nested) * - A plain string * - * Call this anywhere you need to turn a caught API error into a display string: + * Call this anywhere you need to turn a caught API error or error payload into a display string: * * ```ts * import { readApiErrorMessage } from '$lib/api/api-error'; - * import { ApiServiceError } from '$lib/api/api.service'; * * try { * await api.doSomething(); * } catch (err) { - * const payload = err instanceof ApiServiceError ? err.payload : err; - * return fail(500, { error: readApiErrorMessage(payload, m.generic_error()) }); + * return fail(500, { error: readApiErrorMessage(err, m.generic_error()) }); * } * ``` */ export function readApiErrorMessage(payload: unknown, fallback: string): string { - if (payload && typeof payload === 'object') { - const record = payload as Record; - const message = record.message; - - if (typeof message === 'string' && message.trim().length > 0) { - return message.trim(); - } + return readApiErrorMessageValue(payload) ?? fallback; +} - if (Array.isArray(message)) { - const combined = message - .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - .join(', '); - if (combined.length > 0) return combined; - } +function readApiErrorMessageValue(payload: unknown): string | null { + if (typeof payload === 'string') { + const trimmed = payload.trim(); + return trimmed.length > 0 ? trimmed : null; + } - // Recurse into a nested payload envelope. - if (record.payload !== undefined) { - return readApiErrorMessage(record.payload, fallback); - } + if (Array.isArray(payload)) { + const combined = payload + .map(readApiErrorMessageValue) + .filter((entry): entry is string => !!entry) + .join(', '); + return combined.length > 0 ? combined : null; } - if (typeof payload === 'string' && payload.trim().length > 0) { - return payload.trim(); + if (!payload || typeof payload !== 'object') { + return null; } - return fallback; + const record = payload as Record; + + return ( + readApiErrorMessageValue(record.payload) ?? + readApiErrorMessageValue(record.message) ?? + readApiErrorMessageValue(record.error) ?? + readApiErrorMessageValue(record.data) + ); } diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index 3411cdfd..9ade4228 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -185,6 +185,96 @@ export interface CreateRuleRequest extends Partial { export type UpdateRuleRequest = Partial; +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export interface RuleTemplateCriterionDto { + id: number; + templateId: number; + subject: string; + operator: string; + triggerValue: number; + resetValue: number; + createdAt: string | null; +} + +export interface RuleTemplateActionDto { + id: number; + templateId: number; + actionType: number; + actionTypeName?: string | null; + actionTypeValue?: string | null; + config: Json; + createdAt: string | null; +} + +export interface RuleActionTypeDto { + id: number; + actionTypeId: number; + name: string; + value?: string | null; + createdAt: string; +} + +export interface RuleTemplateStateDto { + id: number; + devEui: string; + templateId: number; + isTriggered: boolean; + lastTriggeredAt: string | null; + lastResetAt: string | null; +} + +export interface RuleTemplateAssignmentDto { + id: number; + devEui: string; + templateId: number; + isActive: boolean; + createdAt: string | null; + deviceName: string | null; + permissionLevel: number | null; + state: RuleTemplateStateDto | null; +} + +export interface RuleTemplateDto { + id: number; + name: string; + description: string | null; + deviceTypeId: number | null; + isActive: boolean; + createdAt: string | null; + assignments: RuleTemplateAssignmentDto[]; + criteria: RuleTemplateCriterionDto[]; + actions: RuleTemplateActionDto[]; +} + +export interface RuleTemplateCriterionInput { + id?: number | null; + subject: string; + operator: string; + triggerValue: number; + resetValue: number; +} + +export interface RuleTemplateActionInput { + id?: number | null; + actionType: number; + config: Json; +} + +export interface RuleTemplateSaveRequest { + name: string; + description?: string | null; + deviceTypeId?: number | null; + isActive?: boolean; + devEuis: string[]; + criteria: RuleTemplateCriterionInput[]; + actions: RuleTemplateActionInput[]; +} + +export interface RuleTemplateListQuery { + search?: string; +} + export interface ReportUserScheduleDto { id?: number; dev_eui: string; diff --git a/src/lib/api/api.service.spec.ts b/src/lib/api/api.service.spec.ts index 103226ae..48328a9e 100644 --- a/src/lib/api/api.service.spec.ts +++ b/src/lib/api/api.service.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { ApiService } from './api.service'; +import { readApiErrorMessage } from './api-error'; +import { ApiService, ApiServiceError } from './api.service'; function createJsonResponse(payload: unknown): Response { return new Response(JSON.stringify(payload), { @@ -353,3 +354,165 @@ describe('ApiService relay endpoints', () => { }); }); }); + +describe('ApiService rule template endpoints', () => { + it('lists rule templates through /rules-new with search', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + return createJsonResponse([]); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn, + authToken: 'token-123' + }); + + await api.getRuleTemplates({ search: 'High Temp' }); + + expect(requestedMethod).toBe('GET'); + expect(requestedUrl).toBe('https://example.com/rules-new?search=High+Temp'); + }); + + it('creates rule templates with the documented save payload', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + let requestedBody = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + requestedBody = String(init?.body ?? ''); + return createJsonResponse({ id: 1 }); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.createRuleTemplate({ + name: 'High temp', + description: null, + deviceTypeId: null, + isActive: true, + devEuis: ['ABC123'], + criteria: [ + { + subject: 'temperature_c', + operator: '>', + triggerValue: 30, + resetValue: 25 + } + ], + actions: [ + { + actionType: 1, + config: { + recipient: 'grower@example.com' + } + } + ] + }); + + expect(requestedMethod).toBe('POST'); + expect(requestedUrl).toBe('https://example.com/rules-new'); + expect(JSON.parse(requestedBody)).toEqual({ + name: 'High temp', + description: null, + deviceTypeId: null, + isActive: true, + devEuis: ['ABC123'], + criteria: [ + { + subject: 'temperature_c', + operator: '>', + triggerValue: 30, + resetValue: 25 + } + ], + actions: [ + { + actionType: 1, + config: { + recipient: 'grower@example.com' + } + } + ] + }); + }); + + it('loads rule template action types from the RulesNew action-types endpoint', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + return createJsonResponse([]); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.getRuleTemplateActionTypes(); + + expect(requestedMethod).toBe('GET'); + expect(requestedUrl).toBe('https://example.com/rules-new/action-types'); + }); + + it('uses /rules-new/{id} for read, update, and delete', async () => { + const calls: Array<{ method: string; url: string }> = []; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + method: String(init?.method ?? 'GET'), + url: String(input) + }); + return createJsonResponse({ id: 42 }); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.getRuleTemplate(42); + await api.updateRuleTemplate(42, { + name: 'High temp', + devEuis: ['ABC123'], + criteria: [{ subject: 'temperature_c', operator: '>', triggerValue: 30, resetValue: 25 }], + actions: [{ actionType: 1, config: { recipient: 'grower@example.com' } }] + }); + await api.deleteRuleTemplate(42); + + expect(calls).toEqual([ + { method: 'GET', url: 'https://example.com/rules-new/42' }, + { method: 'PATCH', url: 'https://example.com/rules-new/42' }, + { method: 'DELETE', url: 'https://example.com/rules-new/42' } + ]); + }); +}); + +describe('readApiErrorMessage', () => { + it('prefers nested API payload messages from ApiServiceError objects', () => { + const error = new ApiServiceError(400, 'Bad Request', { + url: 'https://example.com/rules-new', + payload: { + statusCode: 400, + error: 'Bad Request', + message: ['Rule name is required.', 'At least one device is required.'] + } + }); + + expect(readApiErrorMessage(error, 'Fallback')).toBe( + 'Rule name is required., At least one device is required.' + ); + }); +}); diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index 6c217a7c..8b8cad1b 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -22,7 +22,11 @@ import type { PaginationQuery, ReportDto, ReportsQuery, + RuleActionTypeDto, RuleDto, + RuleTemplateDto, + RuleTemplateListQuery, + RuleTemplateSaveRequest, RulesQuery, SensorTimeSeriesPoint, TimeRangeQuery, @@ -128,6 +132,8 @@ const REPORTS_ENDPOINT = '/reports'; const REPORTS_BASE_ENDPOINT = publicEnv.PUBLIC_REPORTS_ENDPOINT ?? REPORTS_ENDPOINT; const REPORT_BY_REPORT_ID_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/{report_id}`; const RULES_BASE_ENDPOINT = publicEnv.PUBLIC_RULES_ENDPOINT ?? '/rules'; +const RULE_TEMPLATES_ENDPOINT = publicEnv.PUBLIC_RULE_TEMPLATES_ENDPOINT ?? '/rules-new'; +const RULE_TEMPLATE_ACTION_TYPES_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/action-types`; const TRIGGERED_RULES_BASE_ENDPOINT = publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT ?? `${RULES_BASE_ENDPOINT}/triggered`; const TRIGGERED_RULES_COUNT_ENDPOINT = @@ -135,6 +141,7 @@ const TRIGGERED_RULES_COUNT_ENDPOINT = const REPORT_HISTORY_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/history/{dev_eui}`; const REPORT_DOWNLOAD_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/download/{dev_eui}/{report_id}/{reportName}`; const RULE_BY_ID_ENDPOINT = `${RULES_BASE_ENDPOINT}/{id}`; +const RULE_TEMPLATE_BY_ID_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}`; const AIR_NOTES_CREATE_ENDPOINT = publicEnv.PUBLIC_AIR_NOTES_ENDPOINT ?? '/air/notes'; const GET_AIR_NOTES_ENDPOINT = publicEnv.PUBLIC_GET_AIR_NOTES_ENDPOINT ?? '/air/notes/{dev_eui}/month/{month}/year/{year}'; @@ -1040,6 +1047,63 @@ export class ApiService { }); } + public getRuleTemplates( + query: RuleTemplateListQuery = {}, + options: ApiMethodOptions = {} + ): Promise { + return this.request(RULE_TEMPLATES_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: { + search: query.search + } + }); + } + + public getRuleTemplate(id: number, options: ApiMethodOptions = {}): Promise { + return this.request(replacePathParams(RULE_TEMPLATE_BY_ID_ENDPOINT, { id }), { + method: 'GET', + signal: options.signal + }); + } + + public getRuleTemplateActionTypes(options: ApiMethodOptions = {}): Promise { + return this.request(RULE_TEMPLATE_ACTION_TYPES_ENDPOINT, { + method: 'GET', + signal: options.signal + }); + } + + public createRuleTemplate( + payload: RuleTemplateSaveRequest, + options: ApiMethodOptions = {} + ): Promise { + return this.request(RULE_TEMPLATES_ENDPOINT, { + method: 'POST', + body: payload, + signal: options.signal + }); + } + + public updateRuleTemplate( + id: number, + payload: RuleTemplateSaveRequest, + options: ApiMethodOptions = {} + ): Promise { + return this.request(replacePathParams(RULE_TEMPLATE_BY_ID_ENDPOINT, { id }), { + method: 'PATCH', + body: payload, + signal: options.signal + }); + } + + public deleteRuleTemplate(id: number, options: ApiMethodOptions = {}): Promise<{ id: number }> { + return this.request<{ id: number }>(replacePathParams(RULE_TEMPLATE_BY_ID_ENDPOINT, { id }), { + method: 'DELETE', + signal: options.signal + }); + } + public createReport(payload: CreateReportRequest): Promise { return this.request(REPORTS_BASE_ENDPOINT, { method: 'POST', diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte index 144d2c91..e611ca90 100644 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte @@ -364,6 +364,16 @@ return sensor.sensor.detailRows ?? []; } + function readSensorExpireAfterMinutes(sensor: DashboardSensorCardEntry): number | undefined { + const rawValue = + sensor.sensor.expectedUpdateAfterMinutes ?? + sensor.sourceDevice.upload_interval ?? + sensor.sourceDevice.raw_data?.default_upload_interval; + const value = Number(rawValue); + + return Number.isFinite(value) && value > 0 ? value : undefined; + } + function readStoredExpandedState(storageKey: string): boolean | null { try { const stored = localStorage.getItem(SENSOR_EXPAND_STORAGE_PREFIX + storageKey); @@ -461,11 +471,8 @@ // created_at for the "Last Update" row — but fall back to the response's value // if liveDevice somehow lacks a valid date, so the row builder always has input. const liveCreatedAt = liveDevice?.created_at; - const liveCreatedAtMs = - liveCreatedAt instanceof Date ? liveCreatedAt.getTime() : NaN; - const createdAtForRow = Number.isFinite(liveCreatedAtMs) - ? liveCreatedAt - : freshData.created_at; + const liveCreatedAtMs = liveCreatedAt instanceof Date ? liveCreatedAt.getTime() : NaN; + const createdAtForRow = Number.isFinite(liveCreatedAtMs) ? liveCreatedAt : freshData.created_at; const dataForRows = { ...freshData, created_at: createdAtForRow }; const isRelay = isRelayTable(liveDevice.data_table) || hasRelayKeys(dataForRows); expandedDetailRowsByDevEui[devEui] = isRelay @@ -507,9 +514,7 @@ const coercedData = Object.fromEntries( Object.entries(latestDevice.raw_data).map(([k, v]) => [ k, - typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) - ? Number(v) - : v + typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : v ]) ); const sensorEntry = locationCards @@ -550,7 +555,11 @@

{m.dashboard_no_matching_locations_body()}

{:else} -
+
{#each visibleLocationCards as card, index (card.id)}
handleSensorExpand(sensor)} onCollapse={() => handleSensorCollapse(sensor)} >
- openDeviceDetails(sensor)} - > + openDeviceDetails(sensor)}> {VIEW_DEVICE_DETAILS_LABEL}
@@ -671,7 +677,8 @@ flex-direction: column; } - .dashboard-device-cards__grid--equal-rows :global(.dashboard-device-cards__location-card.cw-location-card) { + .dashboard-device-cards__grid--equal-rows + :global(.dashboard-device-cards__location-card.cw-location-card) { flex: 1; } @@ -711,34 +718,6 @@ gap: 0.75rem; } - .dashboard-device-cards__device-link { - justify-self: end; - padding: 0.45rem 0.8rem; - border: 1px solid var(--cw-border-default); - border-radius: 999px; - background: var(--cw-bg-surface); - color: var(--cw-text-secondary); - font: inherit; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - transition: - border-color var(--cw-duration-fast) var(--cw-ease-default), - background-color var(--cw-duration-fast) var(--cw-ease-default), - color var(--cw-duration-fast) var(--cw-ease-default); - } - - .dashboard-device-cards__device-link:hover { - border-color: var(--cw-accent); - background: color-mix(in srgb, var(--cw-accent) 10%, var(--cw-bg-surface)); - color: var(--cw-accent); - } - - .dashboard-device-cards__device-link:focus-visible { - outline: 2px solid var(--cw-accent); - outline-offset: 2px; - } - .dashboard-device-cards__prefetch-sentinel { width: 100%; height: 1px; diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts b/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts index a7b518a8..0b89a0ee 100644 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts @@ -18,10 +18,25 @@ vi.mock('$env/dynamic/public', () => ({ })); vi.mock('$app/navigation', () => ({ - goto: gotoMock + afterNavigate: vi.fn(), + beforeNavigate: vi.fn(), + disableScrollHandling: vi.fn(), + goto: gotoMock, + invalidate: vi.fn(), + invalidateAll: vi.fn(), + onNavigate: vi.fn(), + preloadCode: vi.fn(), + preloadData: vi.fn(), + pushState: vi.fn(), + refreshAll: vi.fn(), + replaceState: vi.fn() })); vi.mock('$app/paths', () => ({ + asset: (path: string) => path, + base: '', + assets: '', + match: async () => null, resolve: (path: string, params?: Record) => { if (!params) { return path; @@ -32,6 +47,16 @@ vi.mock('$app/paths', () => ({ } return `/locations/${params.location_id}`; + }, + resolveRoute: (path: string, params?: Record) => { + if (!params) { + return path; + } + + return Object.entries(params).reduce( + (resolved, [key, value]) => resolved.replace(`[${key}]`, value), + path + ); } })); @@ -63,33 +88,31 @@ function createDevice(overrides: Partial = {}): IDevice { }; } -function renderDashboardDeviceCards(options: { - devices?: IDevice[]; - locations?: LocationDto[]; -} = {}) { +function renderDashboardDeviceCards( + options: { + devices?: IDevice[]; + locations?: LocationDto[]; + } = {} +) { const component = mount(DashboardDeviceCardsTestHarness, { target: document.body, props: { initialApp: { accessToken: 'jwt-token', - devices: - options.devices ?? - [ - createDevice(), - createDevice({ - dev_eui: 'dev-2', - created_at: new Date('2026-04-09T09:59:30.000Z') - }) - ], - locations: - options.locations ?? - [ - { - location_id: 1, - name: 'Grow Room', - created_at: '2026-04-01T00:00:00.000Z' - } as LocationDto - ] + devices: options.devices ?? [ + createDevice(), + createDevice({ + dev_eui: 'dev-2', + created_at: new Date('2026-04-09T09:59:30.000Z') + }) + ], + locations: options.locations ?? [ + { + location_id: 1, + name: 'Grow Room', + created_at: '2026-04-01T00:00:00.000Z' + } as LocationDto + ] }, filters: { group: '', @@ -105,8 +128,9 @@ function renderDashboardDeviceCards(options: { } function getButtonsByText(text: string): HTMLButtonElement[] { - return [...document.body.querySelectorAll('button')].filter((button): button is HTMLButtonElement => - Boolean(button.textContent?.includes(text) && !button.closest('[aria-hidden="true"]')) + return [...document.body.querySelectorAll('button')].filter( + (button): button is HTMLButtonElement => + Boolean(button.textContent?.includes(text) && !button.closest('[aria-hidden="true"]')) ); } @@ -148,17 +172,19 @@ describe('DashboardDeviceCards', () => { temperature_c: 25.4, location_id: 1 })); - vi.spyOn(ApiService.prototype, 'getDeviceLatestData').mockImplementation(async (devEui: string) => ({ - dev_eui: devEui, - name: 'Canopy', - location_name: 'Grow Room', - group: 'air', - created_at: '2026-04-09T09:50:00.000Z', - co2: 820, - humidity: 56, - temperature_c: 24.5, - location_id: 1 - })); + vi.spyOn(ApiService.prototype, 'getDeviceLatestData').mockImplementation( + async (devEui: string) => ({ + dev_eui: devEui, + name: 'Canopy', + location_name: 'Grow Room', + group: 'air', + created_at: '2026-04-09T09:50:00.000Z', + co2: 820, + humidity: 56, + temperature_c: 24.5, + location_id: 1 + }) + ); const { component } = renderDashboardDeviceCards(); expect(document.body.textContent).toContain('Canopy (dev-1)'); diff --git a/src/lib/components/dashboard/dashboard-device-data.spec.ts b/src/lib/components/dashboard/dashboard-device-data.spec.ts index cca2724b..0475290a 100644 --- a/src/lib/components/dashboard/dashboard-device-data.spec.ts +++ b/src/lib/components/dashboard/dashboard-device-data.spec.ts @@ -288,7 +288,6 @@ describe('dashboard-device-data helpers', () => { co2: 0, humidity: 0, temperature_c: 18.1, - temperature_c: 18.1, soil_humidity: 31.4, location_id: 612, cwloading: false @@ -311,7 +310,6 @@ describe('dashboard-device-data helpers', () => { dev_eui: 'soil-2', data_table: 'cw_soil_data', temperature_c: 19.2, - temperature_c: 19.2, soil_humidity: 31.4 }); }); diff --git a/src/lib/components/dashboard/dashboard-device-data.ts b/src/lib/components/dashboard/dashboard-device-data.ts index 8b1cb135..da13bfb7 100644 --- a/src/lib/components/dashboard/dashboard-device-data.ts +++ b/src/lib/components/dashboard/dashboard-device-data.ts @@ -67,7 +67,9 @@ function getDeviceDataTable( if (directDataTable) return directDataTable as string; if (isRecord(device.cw_device_type)) { - return (device.cw_device_type.data_table_v2 ?? device.cw_device_type.data_table ?? '') as string; + return (device.cw_device_type.data_table_v2 ?? + device.cw_device_type.data_table ?? + '') as string; } return ''; @@ -78,6 +80,14 @@ function extractDeviceTypeId( ): number | undefined { const raw = (device as Record).type; if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw; + + if (isRecord(device.cw_device_type)) { + const embeddedId = device.cw_device_type.id; + if (typeof embeddedId === 'number' && Number.isFinite(embeddedId) && embeddedId > 0) { + return embeddedId; + } + } + return undefined; } @@ -97,6 +107,31 @@ export interface DeviceTypeLookup { idToModel: Record; } +function mapDeviceTypeRecordToConfig( + deviceType: Record | undefined +): DeviceTypeConfig | undefined { + if (!deviceType) { + return undefined; + } + + const config: DeviceTypeConfig = { + primary_data_key: + typeof deviceType.primary_data_v2 === 'string' ? deviceType.primary_data_v2 : undefined, + secondary_data_key: + typeof deviceType.secondary_data_v2 === 'string' ? deviceType.secondary_data_v2 : undefined, + primary_data_notation: + typeof deviceType.primary_data_notation === 'string' + ? deviceType.primary_data_notation + : undefined, + secondary_data_notation: + typeof deviceType.secondary_data_notation === 'string' + ? deviceType.secondary_data_notation + : undefined + }; + + return Object.values(config).some((value) => value != null) ? config : undefined; +} + /** * Build lookup maps from the full device-type list (`getDeviceTypes()`). * Keyed by `cw_device_type.model` with an id→model bridge so individual @@ -115,22 +150,25 @@ export function buildDeviceTypeLookup(deviceTypes: DeviceTypeDto[]): DeviceTypeL if (byModel[model]) continue; - byModel[model] = { - primary_data_key: dt.primary_data_v2 ?? undefined, - secondary_data_key: dt.secondary_data_v2 ?? undefined, - primary_data_notation: dt.primary_data_notation ?? undefined, - secondary_data_notation: dt.secondary_data_notation ?? undefined, - }; + byModel[model] = mapDeviceTypeRecordToConfig(dt) ?? {}; } return { byModel, idToModel }; } -/** Resolve a DeviceTypeConfig for an IDevice using its type FK. */ +/** Resolve a DeviceTypeConfig for an IDevice using embedded API data first. */ export function resolveDeviceTypeConfig( device: IDevice, - lookup: DeviceTypeLookup | undefined + lookup?: DeviceTypeLookup ): DeviceTypeConfig | undefined { + const embeddedConfig = mapDeviceTypeRecordToConfig( + isRecord(device.raw_data?.cw_device_type) ? device.raw_data.cw_device_type : undefined + ); + + if (embeddedConfig) { + return embeddedConfig; + } + if (!lookup || !device.device_type_id) return undefined; const model = lookup.idToModel[device.device_type_id]; return model ? lookup.byModel[model] : undefined; @@ -164,10 +202,10 @@ export function mapDashboardPrimaryDataToDevice( const moisture = device.moisture; const soilHumidity = moisture != null ? Number(moisture) : null; - // Preserve the full raw payload so dynamic column keys can be resolved + // Preserve raw sensor fields and embedded type config so dynamic keys can be resolved. const raw_data: Record = {}; for (const [key, value] of Object.entries(device)) { - if (key !== 'cw_device_type' && key !== 'cw_locations' && key !== 'cw_device_owners') { + if (key !== 'cw_locations' && key !== 'cw_device_owners') { raw_data[key] = value; } } diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index e8faa28e..4e7cde42 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -42,7 +42,6 @@ function getDeviceLabel(device: IDevice, duplicateCounts: Map): return (duplicateCounts.get(baseLabel) ?? 0) > 1 ? `${baseLabel} (${device.dev_eui})` : baseLabel; } - function getDeviceStatus(device: IDevice, nowMs: number): 'online' | 'offline' { return isDashboardDeviceOffline(device, nowMs) ? 'offline' : 'online'; } @@ -103,7 +102,10 @@ export function buildRelayExpandedDetailRows( return rows; } -function buildUnavailableDetailRows(label: string, typeConfig: DeviceTypeConfig | undefined): CwSensorCardDetailRow[] { +function buildUnavailableDetailRows( + label: string, + typeConfig: DeviceTypeConfig | undefined +): CwSensorCardDetailRow[] { const primaryLabel = typeConfig?.primary_data_key ?? 'Temperature'; const secondaryLabel = typeConfig?.secondary_data_key ?? 'Humidity'; return [ @@ -150,26 +152,40 @@ function getLocationTitle( /** Metadata keys present in the /latest-data payload that are not sensor readings. */ const SENSOR_SKIP_KEYS = new Set([ - 'dev_eui', 'name', 'location_id', 'location_name', 'group', - 'data_table', 'data_table_v2', 'type', 'id', - 'created_at', 'cw_device_type', 'cw_locations', 'cw_device_owners' + 'dev_eui', + 'name', + 'location_id', + 'location_name', + 'group', + 'data_table', + 'data_table_v2', + 'type', + 'id', + 'created_at', + 'cw_device_type', + 'cw_locations', + 'cw_device_owners' ]); -interface SensorFieldMeta { label: string; unit: string; icon: string } +interface SensorFieldMeta { + label: string; + unit: string; + icon: string; +} const KNOWN_SENSOR_FIELDS: Record = { - temperature_c: { label: 'Temperature', unit: '°C', icon: 'thermo' }, - humidity: { label: 'Humidity', unit: '%', icon: 'drop' }, - co2: { label: 'CO₂', unit: 'ppm', icon: 'thermo' }, - moisture: { label: 'Moisture', unit: '%', icon: 'drop' }, - ec: { label: 'EC', unit: 'dS/m', icon: 'drop' }, - battery: { label: 'Battery', unit: '%', icon: 'timer' }, - pressure: { label: 'Pressure', unit: 'hPa', icon: 'thermo' }, - light: { label: 'Light', unit: 'lux', icon: 'thermo' }, - light_level: { label: 'Light', unit: 'lux', icon: 'thermo' }, - voltage: { label: 'Voltage', unit: 'V', icon: 'timer' }, - deapth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, - depth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, + temperature_c: { label: 'Temperature', unit: '°C', icon: 'thermo' }, + humidity: { label: 'Humidity', unit: '%', icon: 'drop' }, + co2: { label: 'CO₂', unit: 'ppm', icon: 'thermo' }, + moisture: { label: 'Moisture', unit: '%', icon: 'drop' }, + ec: { label: 'EC', unit: 'dS/m', icon: 'drop' }, + battery: { label: 'Battery', unit: '%', icon: 'timer' }, + pressure: { label: 'Pressure', unit: 'hPa', icon: 'thermo' }, + light: { label: 'Light', unit: 'lux', icon: 'thermo' }, + light_level: { label: 'Light', unit: 'lux', icon: 'thermo' }, + voltage: { label: 'Voltage', unit: 'V', icon: 'timer' }, + deapth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, + depth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' } }; function formatKeyLabel(key: string): string { @@ -315,8 +331,15 @@ function buildDashboardSensorCardEntry( }; } - const rawPrimary = device.raw_data?.[typeConfig?.primary_data_key]; - const rawSecondary = device.raw_data?.[typeConfig?.secondary_data_key] || null; + const primaryDataKey = typeConfig?.primary_data_key ?? 'temperature_c'; + const secondaryDataKey = + typeConfig?.secondary_data_key ?? + (device.data_table === 'cw_soil_data' ? 'moisture' : 'humidity'); + const rawPrimary = device.raw_data?.[primaryDataKey] ?? device.temperature_c; + const rawSecondary = + device.raw_data?.[secondaryDataKey] ?? + (secondaryDataKey === 'moisture' ? device.soil_humidity : device.humidity) ?? + null; return { id: `sensor:${device.dev_eui}`, @@ -330,7 +353,7 @@ function buildDashboardSensorCardEntry( primaryUnit: typeConfig?.primary_data_notation ?? '°C', ...(rawSecondary !== null && { secondaryValue: typeof rawSecondary === 'number' ? rawSecondary : Number(rawSecondary) || 0, - secondaryUnit: typeConfig?.secondary_data_notation ?? '%', + secondaryUnit: typeConfig?.secondary_data_notation ?? '%' }), status: getDeviceStatus(device, nowMs), lastUpdated: device.created_at diff --git a/src/lib/components/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte index ace48324..fe573cbc 100644 --- a/src/lib/components/layout/AppPage.svelte +++ b/src/lib/components/layout/AppPage.svelte @@ -50,7 +50,7 @@ @media (min-width: 640px) { .app-page { - padding-block: var(--cw-space-4); + padding-block: var(--cw-space-2); } } diff --git a/src/lib/interfaces/device.interface.ts b/src/lib/interfaces/device.interface.ts index 48198805..e430288c 100644 --- a/src/lib/interfaces/device.interface.ts +++ b/src/lib/interfaces/device.interface.ts @@ -13,6 +13,7 @@ export interface IDevice { cwloading?: boolean; location_id: number; alert_count?: number; + upload_interval?: number; /** cw_device_type.id FK – used to look up device-type config */ device_type_id?: number; diff --git a/src/lib/rules-new/rule-template-client.ts b/src/lib/rules-new/rule-template-client.ts deleted file mode 100644 index 47d7a098..00000000 --- a/src/lib/rules-new/rule-template-client.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - RuleTemplateDto, - RuleTemplateListQuery, - RuleTemplateSaveRequest -} from './rule-template.types'; - -type FetchLike = typeof fetch; - -export class RuleTemplateApiError extends Error { - public readonly status: number; - public readonly payload: unknown; - - public constructor(status: number, payload: unknown) { - super(`Rule template API request failed (${status})`); - this.name = 'RuleTemplateApiError'; - this.status = status; - this.payload = payload; - } -} - -export async function listRuleTemplates( - query: RuleTemplateListQuery = {}, - options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} -): Promise { - const search = query.search?.trim(); - const params = new URLSearchParams(); - if (search) params.set('search', search); - - const suffix = params.toString(); - return requestJson(`/api/rules-new${suffix ? `?${suffix}` : ''}`, { - fetchFn: options.fetchFn, - signal: options.signal - }); -} - -export function getRuleTemplate( - id: number, - options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} -): Promise { - return requestJson(`/api/rules-new/${encodeURIComponent(String(id))}`, { - fetchFn: options.fetchFn, - signal: options.signal - }); -} - -export function createRuleTemplate( - payload: RuleTemplateSaveRequest, - options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} -): Promise { - return requestJson('/api/rules-new', { - method: 'POST', - body: payload, - fetchFn: options.fetchFn, - signal: options.signal - }); -} - -export function updateRuleTemplate( - id: number, - payload: RuleTemplateSaveRequest, - options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} -): Promise { - return requestJson(`/api/rules-new/${encodeURIComponent(String(id))}`, { - method: 'PATCH', - body: payload, - fetchFn: options.fetchFn, - signal: options.signal - }); -} - -export function deleteRuleTemplate( - id: number, - options: { fetchFn?: FetchLike; signal?: AbortSignal } = {} -): Promise<{ id: number }> { - return requestJson<{ id: number }>(`/api/rules-new/${encodeURIComponent(String(id))}`, { - method: 'DELETE', - fetchFn: options.fetchFn, - signal: options.signal - }); -} - -export function readRuleTemplateApiError(error: unknown, fallback: string): string { - if (error instanceof RuleTemplateApiError) { - return readMessage(error.payload) ?? fallback; - } - - if (error instanceof Error) { - const message = error.message.trim(); - return message.length > 0 ? message : fallback; - } - - return fallback; -} - -async function requestJson( - path: string, - options: { - method?: string; - body?: unknown; - fetchFn?: FetchLike; - signal?: AbortSignal; - } = {} -): Promise { - const fetchFn = options.fetchFn ?? fetch; - const headers = new Headers(); - headers.set('Accept', 'application/json'); - - let body: string | undefined; - if (options.body !== undefined) { - headers.set('Content-Type', 'application/json'); - body = JSON.stringify(options.body); - } - - const response = await fetchFn(path, { - method: options.method ?? 'GET', - headers, - body, - signal: options.signal, - credentials: 'same-origin' - }); - const payload = await parsePayload(response); - - if (!response.ok) { - throw new RuleTemplateApiError(response.status, payload); - } - - return payload as T; -} - -async function parsePayload(response: Response): Promise { - const text = await response.text(); - if (!text) return null; - - try { - return JSON.parse(text); - } catch { - return text; - } -} - -function readMessage(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - - if (Array.isArray(value)) { - for (const item of value) { - const message = readMessage(item); - if (message) return message; - } - return null; - } - - if (!isRecord(value)) { - return null; - } - - return readMessage(value.message) ?? readMessage(value.error) ?? readMessage(value.payload); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/src/lib/rules-new/rule-template.types.ts b/src/lib/rules-new/rule-template.types.ts index a9cf2977..6d166f6e 100644 --- a/src/lib/rules-new/rule-template.types.ts +++ b/src/lib/rules-new/rule-template.types.ts @@ -1,79 +1,13 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; - -export interface RuleTemplateCriterionDto { - id: number; - templateId: number; - subject: string; - operator: string; - triggerValue: number; - resetValue: number; - createdAt: string | null; -} - -export interface RuleTemplateActionDto { - id: number; - templateId: number; - actionType: string; - config: Json; - createdAt: string | null; -} - -export interface RuleTemplateStateDto { - id: number; - devEui: string; - templateId: number; - isTriggered: boolean; - lastTriggeredAt: string | null; - lastResetAt: string | null; -} - -export interface RuleTemplateAssignmentDto { - id: number; - devEui: string; - templateId: number; - isActive: boolean; - createdAt: string | null; - deviceName: string | null; - permissionLevel: number | null; - state: RuleTemplateStateDto | null; -} - -export interface RuleTemplateDto { - id: number; - name: string; - description: string | null; - deviceTypeId: number | null; - isActive: boolean; - createdAt: string | null; - assignments: RuleTemplateAssignmentDto[]; - criteria: RuleTemplateCriterionDto[]; - actions: RuleTemplateActionDto[]; -} - -export interface RuleTemplateCriterionInput { - id?: number | null; - subject: string; - operator: string; - triggerValue: number; - resetValue: number; -} - -export interface RuleTemplateActionInput { - id?: number | null; - actionType: string; - config: Json; -} - -export interface RuleTemplateSaveRequest { - name: string; - description?: string | null; - deviceTypeId?: number | null; - isActive?: boolean; - devEuis: string[]; - criteria: RuleTemplateCriterionInput[]; - actions: RuleTemplateActionInput[]; -} - -export interface RuleTemplateListQuery { - search?: string; -} +export type { + Json, + RuleActionTypeDto, + RuleTemplateActionDto, + RuleTemplateActionInput, + RuleTemplateAssignmentDto, + RuleTemplateCriterionDto, + RuleTemplateCriterionInput, + RuleTemplateDto, + RuleTemplateListQuery, + RuleTemplateSaveRequest, + RuleTemplateStateDto +} from '$lib/api/api.dtos'; diff --git a/src/lib/server/rules-new/route-helpers.ts b/src/lib/server/rules-new/route-helpers.ts deleted file mode 100644 index cb9e110e..00000000 --- a/src/lib/server/rules-new/route-helpers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { m } from '$lib/paraglide/messages.js'; -import { json } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; -import { RuleTemplateRepositoryError } from './rule-template.repository'; - -export interface RuleTemplateRequestContext { - authToken: string; - userId: string; - fetchFn: typeof fetch; -} - -export function requireRuleTemplateRequestContext( - event: RequestEvent -): RuleTemplateRequestContext | Response { - const authToken = event.locals.jwtString ?? null; - const userId = event.locals.jwt?.sub ?? null; - - if (!authToken || !userId) { - return json({ message: m.auth_not_authenticated() }, { status: 401 }); - } - - return { - authToken, - userId, - fetchFn: event.fetch - }; -} - -export function parseRuleTemplateId(rawId: string | undefined): number | Response { - const id = Number(rawId); - if (!Number.isInteger(id) || id <= 0) { - return json({ message: m.rules_new_invalid_template_id() }, { status: 400 }); - } - - return id; -} - -export function repositoryErrorResponse(error: unknown, fallback: string): Response { - if (error instanceof RuleTemplateRepositoryError) { - if (error.status >= 500) { - console.error(fallback, error); - } - return json({ message: fallback }, { status: error.status }); - } - - console.error(fallback, error); - return json({ message: fallback }, { status: 500 }); -} diff --git a/src/lib/server/rules-new/rule-template.repository.ts b/src/lib/server/rules-new/rule-template.repository.ts deleted file mode 100644 index b8fb7775..00000000 --- a/src/lib/server/rules-new/rule-template.repository.ts +++ /dev/null @@ -1,917 +0,0 @@ -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { ApiService } from '$lib/api/api.service'; -import type { DeviceDto } from '$lib/api/api.dtos'; -import type { - Json, - RuleTemplateActionDto, - RuleTemplateActionInput, - RuleTemplateAssignmentDto, - RuleTemplateCriterionDto, - RuleTemplateCriterionInput, - RuleTemplateDto, - RuleTemplateListQuery, - RuleTemplateSaveRequest, - RuleTemplateStateDto -} from '$lib/rules-new/rule-template.types'; -import { createClient, type SupabaseClient } from '@supabase/supabase-js'; - -type FetchLike = typeof fetch; - -interface RuleTemplatesDatabase { - public: { - Tables: { - cw_device_rule_assignments: { - Row: { - created_at: string | null; - dev_eui: string; - id: number; - is_active: boolean | null; - template_id: number; - }; - Insert: { - created_at?: string | null; - dev_eui: string; - id?: number; - is_active?: boolean | null; - template_id: number; - }; - Update: { - created_at?: string | null; - dev_eui?: string; - id?: number; - is_active?: boolean | null; - template_id?: number; - }; - Relationships: []; - }; - cw_rule_templates: { - Row: { - created_at: string | null; - description: string | null; - device_type_id: number | null; - id: number; - is_active: boolean | null; - name: string; - }; - Insert: { - created_at?: string | null; - description?: string | null; - device_type_id?: number | null; - id?: number; - is_active?: boolean | null; - name: string; - }; - Update: { - created_at?: string | null; - description?: string | null; - device_type_id?: number | null; - id?: number; - is_active?: boolean | null; - name?: string; - }; - Relationships: []; - }; - cw_rule_template_criteria: { - Row: { - created_at: string | null; - id: number; - operator: string; - reset_value: number; - subject: string; - template_id: number; - trigger_value: number; - }; - Insert: { - created_at?: string | null; - id?: number; - operator: string; - reset_value: number; - subject: string; - template_id: number; - trigger_value: number; - }; - Update: { - created_at?: string | null; - id?: number; - operator?: string; - reset_value?: number; - subject?: string; - template_id?: number; - trigger_value?: number; - }; - Relationships: []; - }; - cw_rule_template_actions: { - Row: { - action_type: string; - config: Json; - created_at: string | null; - id: number; - template_id: number; - }; - Insert: { - action_type: string; - config: Json; - created_at?: string | null; - id?: number; - template_id: number; - }; - Update: { - action_type?: string; - config?: Json; - created_at?: string | null; - id?: number; - template_id?: number; - }; - Relationships: []; - }; - cw_rule_state: { - Row: { - dev_eui: string; - id: number; - is_triggered: boolean; - last_reset_at: string | null; - last_triggered_at: string | null; - template_id: number; - }; - Insert: { - dev_eui: string; - id?: number; - is_triggered?: boolean; - last_reset_at?: string | null; - last_triggered_at?: string | null; - template_id: number; - }; - Update: { - dev_eui?: string; - id?: number; - is_triggered?: boolean; - last_reset_at?: string | null; - last_triggered_at?: string | null; - template_id?: number; - }; - Relationships: []; - }; - }; - Views: Record; - Functions: Record; - Enums: Record; - CompositeTypes: Record; - }; -} - -type RuleTemplateRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_templates']['Row']; -type RuleAssignmentRow = - RuleTemplatesDatabase['public']['Tables']['cw_device_rule_assignments']['Row']; -type RuleCriterionRow = - RuleTemplatesDatabase['public']['Tables']['cw_rule_template_criteria']['Row']; -type RuleActionRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_template_actions']['Row']; -type RuleStateRow = RuleTemplatesDatabase['public']['Tables']['cw_rule_state']['Row']; - -interface RuleTemplateRepositoryContext { - authToken: string; - userId: string; - fetchFn: FetchLike; -} - -interface ManagedDevice { - devEui: string; - name: string | null; - permissionLevel: number | null; - canView: boolean; - canManage: boolean; - type: number | null; -} - -interface NormalizedRuleTemplateSaveRequest { - name: string; - description: string | null; - deviceTypeId: number | null; - isActive: boolean; - devEuis: string[]; - criteria: RuleTemplateCriterionInput[]; - actions: RuleTemplateActionInput[]; -} - -export class RuleTemplateRepositoryError extends Error { - public readonly status: number; - - public constructor(status: number, message: string) { - super(message); - this.name = 'RuleTemplateRepositoryError'; - this.status = status; - } -} - -export async function listRuleTemplatesForUser( - context: RuleTemplateRepositoryContext, - query: RuleTemplateListQuery = {} -): Promise { - const devices = await listManagedDevices(context); - const viewableDevices = devices.filter((device) => device.canView); - if (viewableDevices.length === 0) return []; - - const client = createRuleTemplateClient(context.authToken); - const assignmentsResult = await client - .from('cw_device_rule_assignments') - .select('created_at, dev_eui, id, is_active, template_id') - .in( - 'dev_eui', - viewableDevices.map((device) => device.devEui) - ); - - if (assignmentsResult.error) { - throwSupabaseError('Failed to load rule assignments', assignmentsResult.error); - } - - const assignments = assignmentsResult.data ?? []; - const templateIds = uniqueIds(assignments.map((assignment) => assignment.template_id)); - if (templateIds.length === 0) return []; - - const templates = await loadTemplatesByIds(client, templateIds); - const criteria = await loadCriteriaByTemplateIds(client, templateIds); - const actions = await loadActionsByTemplateIds(client, templateIds); - const states = await loadStates( - client, - templateIds, - assignments.map((assignment) => assignment.dev_eui) - ); - - const rules = buildRuleTemplates({ - templates, - assignments, - criteria, - actions, - states, - devices - }); - - const search = query.search?.trim().toLowerCase(); - if (!search) return rules; - - return rules.filter((rule) => matchesSearch(rule, search)); -} - -export async function getRuleTemplateForUser( - context: RuleTemplateRepositoryContext, - templateId: number -): Promise { - const devices = await listManagedDevices(context); - const viewableDevices = devices.filter((device) => device.canView); - if (viewableDevices.length === 0) { - throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); - } - - const client = createRuleTemplateClient(context.authToken); - const [template, assignmentsResult] = await Promise.all([ - loadTemplateById(client, templateId), - client - .from('cw_device_rule_assignments') - .select('created_at, dev_eui, id, is_active, template_id') - .eq('template_id', templateId) - .in( - 'dev_eui', - viewableDevices.map((device) => device.devEui) - ) - ]); - - if (assignmentsResult.error) { - throwSupabaseError('Failed to load rule assignments', assignmentsResult.error); - } - - const assignments = assignmentsResult.data ?? []; - if (assignments.length === 0) { - throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); - } - - const [criteria, actions, states] = await Promise.all([ - loadCriteriaByTemplateIds(client, [templateId]), - loadActionsByTemplateIds(client, [templateId]), - loadStates( - client, - [templateId], - assignments.map((assignment) => assignment.dev_eui) - ) - ]); - - const [rule] = buildRuleTemplates({ - templates: [template], - assignments, - criteria, - actions, - states, - devices - }); - - if (!rule) { - throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); - } - - return rule; -} - -export async function createRuleTemplateForUser( - context: RuleTemplateRepositoryContext, - input: unknown -): Promise { - const payload = normalizeRuleTemplateSaveRequest(input); - const devices = await listManagedDevices(context); - assertDevicesCanBeManaged(devices, payload.devEuis); - - const client = createRuleTemplateClient(context.authToken); - const insertResult = await client - .from('cw_rule_templates') - .insert({ - name: payload.name, - description: payload.description, - device_type_id: payload.deviceTypeId, - is_active: payload.isActive - }) - .select('created_at, description, device_type_id, id, is_active, name') - .single(); - - if (insertResult.error) { - throwSupabaseError('Failed to create rule template', insertResult.error); - } - - const template = insertResult.data; - try { - await replaceTemplateChildren(client, template.id, payload); - } catch (error) { - await deleteTemplateBestEffort(client, template.id); - throw error; - } - - return getRuleTemplateForUser(context, template.id); -} - -export async function updateRuleTemplateForUser( - context: RuleTemplateRepositoryContext, - templateId: number, - input: unknown -): Promise { - const payload = normalizeRuleTemplateSaveRequest(input); - const existing = await getRuleTemplateForUser(context, templateId); - const devices = await listManagedDevices(context); - - assertDevicesCanBeManaged( - devices, - uniqueIds([...existing.assignments.map((assignment) => assignment.devEui), ...payload.devEuis]) - ); - - const client = createRuleTemplateClient(context.authToken); - const updateResult = await client - .from('cw_rule_templates') - .update({ - name: payload.name, - description: payload.description, - device_type_id: payload.deviceTypeId, - is_active: payload.isActive - }) - .eq('id', templateId); - - if (updateResult.error) { - throwSupabaseError('Failed to update rule template', updateResult.error); - } - - await replaceTemplateChildren(client, templateId, payload); - await deleteTemplateState(client, templateId); - - return getRuleTemplateForUser(context, templateId); -} - -export async function deleteRuleTemplateForUser( - context: RuleTemplateRepositoryContext, - templateId: number -): Promise<{ id: number }> { - const existing = await getRuleTemplateForUser(context, templateId); - const devices = await listManagedDevices(context); - assertDevicesCanBeManaged( - devices, - existing.assignments.map((assignment) => assignment.devEui) - ); - - const client = createRuleTemplateClient(context.authToken); - await deleteTemplateState(client, templateId); - await deleteTemplateChildren(client, templateId); - - const deleteResult = await client.from('cw_rule_templates').delete().eq('id', templateId); - if (deleteResult.error) { - throwSupabaseError('Failed to delete rule template', deleteResult.error); - } - - return { id: templateId }; -} - -function createRuleTemplateClient(authToken: string): SupabaseClient { - if (!PUBLIC_SUPABASE_URL || !PUBLIC_SUPABASE_ANON_KEY) { - throw new RuleTemplateRepositoryError(500, 'Supabase is not configured.'); - } - - return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - auth: { - autoRefreshToken: false, - persistSession: false - }, - global: { - headers: { - Authorization: `Bearer ${authToken}` - } - } - }); -} - -async function listManagedDevices( - context: RuleTemplateRepositoryContext -): Promise { - const api = new ApiService({ fetchFn: context.fetchFn, authToken: context.authToken }); - const devices = await api.getAllDevices().catch((error) => { - console.error('Failed to load devices for rule template access checks:', error); - return []; - }); - - return devices.map((device) => normalizeManagedDevice(device, context.userId)); -} - -function normalizeManagedDevice(device: DeviceDto, userId: string): ManagedDevice { - const permissionLevel = readDevicePermissionLevel(device, userId); - const devEui = typeof device.dev_eui === 'string' ? device.dev_eui : ''; - const name = typeof device.name === 'string' && device.name.trim() ? device.name : null; - const type = typeof device.type === 'number' && Number.isFinite(device.type) ? device.type : null; - - return { - devEui, - name, - type, - permissionLevel, - canView: devEui.length > 0 && (permissionLevel == null || permissionLevel <= 3), - canManage: devEui.length > 0 && (permissionLevel == null || permissionLevel <= 2) - }; -} - -function readDevicePermissionLevel(device: DeviceDto, userId: string): number | null { - const directPermissionLevel = readFiniteNumber( - (device as Record).permission_level - ); - if (directPermissionLevel !== null) return directPermissionLevel; - - if (device.user_id === userId) return 1; - - const owners = Array.isArray(device.cw_device_owners) ? device.cw_device_owners : []; - const owner = owners.find((entry) => entry.user_id === userId); - const ownerPermissionLevel = readFiniteNumber(owner?.permission_level); - - return ownerPermissionLevel; -} - -function assertDevicesCanBeManaged(devices: ManagedDevice[], devEuis: string[]): void { - const manageableDeviceIds = new Set( - devices.filter((device) => device.canManage).map((device) => device.devEui) - ); - const missingDevice = devEuis.find((devEui) => !manageableDeviceIds.has(devEui)); - - if (missingDevice) { - throw new RuleTemplateRepositoryError( - 403, - 'You do not have permission to manage one or more selected devices.' - ); - } -} - -async function loadTemplateById( - client: SupabaseClient, - templateId: number -): Promise { - const result = await client - .from('cw_rule_templates') - .select('created_at, description, device_type_id, id, is_active, name') - .eq('id', templateId) - .maybeSingle(); - - if (result.error) { - throwSupabaseError('Failed to load rule template', result.error); - } - - if (!result.data) { - throw new RuleTemplateRepositoryError(404, 'Rule template not found.'); - } - - return result.data; -} - -async function loadTemplatesByIds( - client: SupabaseClient, - templateIds: number[] -): Promise { - const result = await client - .from('cw_rule_templates') - .select('created_at, description, device_type_id, id, is_active, name') - .in('id', templateIds); - - if (result.error) { - throwSupabaseError('Failed to load rule templates', result.error); - } - - return result.data ?? []; -} - -async function loadCriteriaByTemplateIds( - client: SupabaseClient, - templateIds: number[] -): Promise { - const result = await client - .from('cw_rule_template_criteria') - .select('created_at, id, operator, reset_value, subject, template_id, trigger_value') - .in('template_id', templateIds); - - if (result.error) { - throwSupabaseError('Failed to load rule criteria', result.error); - } - - return result.data ?? []; -} - -async function loadActionsByTemplateIds( - client: SupabaseClient, - templateIds: number[] -): Promise { - const result = await client - .from('cw_rule_template_actions') - .select('action_type, config, created_at, id, template_id') - .in('template_id', templateIds); - - if (result.error) { - throwSupabaseError('Failed to load rule actions', result.error); - } - - return result.data ?? []; -} - -async function loadStates( - client: SupabaseClient, - templateIds: number[], - devEuis: string[] -): Promise { - const uniqueDevEuis = uniqueIds(devEuis); - if (templateIds.length === 0 || uniqueDevEuis.length === 0) return []; - - const result = await client - .from('cw_rule_state') - .select('dev_eui, id, is_triggered, last_reset_at, last_triggered_at, template_id') - .in('template_id', templateIds) - .in('dev_eui', uniqueDevEuis); - - if (result.error) { - throwSupabaseError('Failed to load rule state', result.error); - } - - return result.data ?? []; -} - -async function replaceTemplateChildren( - client: SupabaseClient, - templateId: number, - payload: NormalizedRuleTemplateSaveRequest -): Promise { - await deleteTemplateChildren(client, templateId); - - const assignments = payload.devEuis.map((devEui) => ({ - dev_eui: devEui, - template_id: templateId, - is_active: true - })); - const criteria = payload.criteria.map((criterion) => ({ - template_id: templateId, - subject: criterion.subject, - operator: criterion.operator, - trigger_value: criterion.triggerValue, - reset_value: criterion.resetValue - })); - const actions = payload.actions.map((action) => ({ - template_id: templateId, - action_type: action.actionType, - config: action.config - })); - - const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ - client.from('cw_device_rule_assignments').insert(assignments), - client.from('cw_rule_template_criteria').insert(criteria), - client.from('cw_rule_template_actions').insert(actions) - ]); - - if (assignmentsResult.error) { - throwSupabaseError('Failed to save rule assignments', assignmentsResult.error); - } - if (criteriaResult.error) { - throwSupabaseError('Failed to save rule criteria', criteriaResult.error); - } - if (actionsResult.error) { - throwSupabaseError('Failed to save rule actions', actionsResult.error); - } -} - -async function deleteTemplateChildren( - client: SupabaseClient, - templateId: number -): Promise { - const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ - client.from('cw_device_rule_assignments').delete().eq('template_id', templateId), - client.from('cw_rule_template_criteria').delete().eq('template_id', templateId), - client.from('cw_rule_template_actions').delete().eq('template_id', templateId) - ]); - - if (assignmentsResult.error) { - throwSupabaseError('Failed to remove rule assignments', assignmentsResult.error); - } - if (criteriaResult.error) { - throwSupabaseError('Failed to remove rule criteria', criteriaResult.error); - } - if (actionsResult.error) { - throwSupabaseError('Failed to remove rule actions', actionsResult.error); - } -} - -async function deleteTemplateState( - client: SupabaseClient, - templateId: number -): Promise { - const result = await client.from('cw_rule_state').delete().eq('template_id', templateId); - if (result.error) { - throwSupabaseError('Failed to reset rule state', result.error); - } -} - -async function deleteTemplateBestEffort( - client: SupabaseClient, - templateId: number -): Promise { - try { - await deleteTemplateChildren(client, templateId); - await client.from('cw_rule_templates').delete().eq('id', templateId); - } catch (error) { - console.error('Failed to clean up partially-created rule template:', { templateId, error }); - } -} - -function buildRuleTemplates({ - templates, - assignments, - criteria, - actions, - states, - devices -}: { - templates: RuleTemplateRow[]; - assignments: RuleAssignmentRow[]; - criteria: RuleCriterionRow[]; - actions: RuleActionRow[]; - states: RuleStateRow[]; - devices: ManagedDevice[]; -}): RuleTemplateDto[] { - const devicesById = new Map(devices.map((device) => [device.devEui, device])); - const assignmentsByTemplateId = groupBy(assignments, (assignment) => assignment.template_id); - const criteriaByTemplateId = groupBy(criteria, (criterion) => criterion.template_id); - const actionsByTemplateId = groupBy(actions, (action) => action.template_id); - const statesByTemplateAndDevice = new Map( - states.map((state) => [`${state.template_id}:${state.dev_eui}`, state]) - ); - - return templates - .map((template): RuleTemplateDto | null => { - const ruleAssignments = assignmentsByTemplateId.get(template.id) ?? []; - if (ruleAssignments.length === 0) return null; - - return { - id: template.id, - name: template.name, - description: template.description, - deviceTypeId: template.device_type_id, - isActive: template.is_active ?? true, - createdAt: template.created_at, - assignments: ruleAssignments.map((assignment): RuleTemplateAssignmentDto => { - const device = devicesById.get(assignment.dev_eui); - const state = statesByTemplateAndDevice.get( - `${assignment.template_id}:${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, - permissionLevel: device?.permissionLevel ?? null, - state: state ? mapState(state) : null - }; - }), - criteria: (criteriaByTemplateId.get(template.id) ?? []).map(mapCriterion), - actions: (actionsByTemplateId.get(template.id) ?? []).map(mapAction) - }; - }) - .filter((rule): rule is RuleTemplateDto => rule !== null) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -function mapCriterion(row: RuleCriterionRow): RuleTemplateCriterionDto { - return { - id: row.id, - templateId: row.template_id, - subject: row.subject, - operator: row.operator, - triggerValue: row.trigger_value, - resetValue: row.reset_value, - createdAt: row.created_at - }; -} - -function mapAction(row: RuleActionRow): RuleTemplateActionDto { - return { - id: row.id, - templateId: row.template_id, - actionType: row.action_type, - config: row.config, - createdAt: row.created_at - }; -} - -function mapState(row: RuleStateRow): RuleTemplateStateDto { - return { - id: row.id, - devEui: row.dev_eui, - templateId: row.template_id, - isTriggered: row.is_triggered, - lastTriggeredAt: row.last_triggered_at, - lastResetAt: row.last_reset_at - }; -} - -function normalizeRuleTemplateSaveRequest(input: unknown): NormalizedRuleTemplateSaveRequest { - if (!isRecord(input)) { - throw new RuleTemplateRepositoryError(400, 'Invalid rule template payload.'); - } - - const name = typeof input.name === 'string' ? input.name.trim() : ''; - if (!name) { - throw new RuleTemplateRepositoryError(400, 'Rule name is required.'); - } - - const devEuis = normalizeStringArray(input.devEuis); - if (devEuis.length === 0) { - throw new RuleTemplateRepositoryError(400, 'At least one device is required.'); - } - - const criteria = normalizeCriteria(input.criteria); - if (criteria.length === 0) { - throw new RuleTemplateRepositoryError(400, 'At least one condition is required.'); - } - - const actions = normalizeActions(input.actions); - if (actions.length === 0) { - throw new RuleTemplateRepositoryError(400, 'At least one action is required.'); - } - - return { - name, - description: - typeof input.description === 'string' && input.description.trim() - ? input.description.trim() - : null, - deviceTypeId: readFiniteNumber(input.deviceTypeId), - isActive: typeof input.isActive === 'boolean' ? input.isActive : true, - devEuis, - criteria, - actions - }; -} - -function normalizeCriteria(input: unknown): RuleTemplateCriterionInput[] { - if (!Array.isArray(input)) { - throw new RuleTemplateRepositoryError(400, 'Rule criteria must be an array.'); - } - - return input.map((item, index) => { - if (!isRecord(item)) { - throw new RuleTemplateRepositoryError(400, `Condition ${index + 1} is invalid.`); - } - - const subject = typeof item.subject === 'string' ? item.subject.trim() : ''; - const operator = typeof item.operator === 'string' ? item.operator.trim() : ''; - const triggerValue = readFiniteNumber(item.triggerValue); - const resetValue = readFiniteNumber(item.resetValue); - - if (!subject || !operator || triggerValue === null || resetValue === null) { - throw new RuleTemplateRepositoryError( - 400, - `Condition ${index + 1} must include a field, operator, trigger value, and reset value.` - ); - } - - return { - id: readFiniteNumber(item.id), - subject, - operator, - triggerValue, - resetValue - }; - }); -} - -function normalizeActions(input: unknown): RuleTemplateActionInput[] { - if (!Array.isArray(input)) { - throw new RuleTemplateRepositoryError(400, 'Rule actions must be an array.'); - } - - return input.map((item, index) => { - if (!isRecord(item)) { - throw new RuleTemplateRepositoryError(400, `Action ${index + 1} is invalid.`); - } - - const actionType = typeof item.actionType === 'string' ? item.actionType.trim() : ''; - if (!actionType) { - throw new RuleTemplateRepositoryError(400, `Action ${index + 1} needs a type.`); - } - - const config = item.config; - if (!isJson(config)) { - throw new RuleTemplateRepositoryError(400, `Action ${index + 1} has invalid config.`); - } - - return { - id: readFiniteNumber(item.id), - actionType, - config - }; - }); -} - -function normalizeStringArray(input: unknown): string[] { - if (!Array.isArray(input)) return []; - - return uniqueIds( - input - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0) - ); -} - -function matchesSearch(rule: RuleTemplateDto, search: string): boolean { - const deviceText = rule.assignments - .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) - .join(' '); - - return [rule.name, rule.description ?? '', deviceText].join(' ').toLowerCase().includes(search); -} - -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) ?? []; - existing.push(item); - result.set(groupKey, existing); - } - return result; -} - -function uniqueIds(values: T[]): T[] { - return [...new Set(values)]; -} - -function readFiniteNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim()) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -} - -function isJson(value: unknown): value is Json { - if ( - value === null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return typeof value !== 'number' || Number.isFinite(value); - } - - if (Array.isArray(value)) { - return value.every(isJson); - } - - if (!isRecord(value)) { - return false; - } - - return Object.values(value).every((entry) => entry === undefined || isJson(entry)); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function throwSupabaseError(context: string, error: { message: string }): never { - throw new RuleTemplateRepositoryError(500, `${context}: ${error.message}`); -} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 863b00f9..483ef6b5 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,9 +1,73 @@ import { ApiService } from '$lib/api/api.service'; +import type { DeviceStatusSummary, RuleDto } from '$lib/api/api.dtos'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async ({ locals, fetch }) => { +const EMPTY_DEVICE_STATUSES: DeviceStatusSummary = { online: 0, offline: 0 }; + +function readTriggeredRulesCount(rawTriggeredRulesCount: unknown): number { + if (typeof rawTriggeredRulesCount === 'number' && Number.isFinite(rawTriggeredRulesCount)) { + return rawTriggeredRulesCount; + } + + if (rawTriggeredRulesCount && typeof rawTriggeredRulesCount === 'object') { + const record = rawTriggeredRulesCount as Record; + const maybeCount = record.count ?? record.triggered_count; + + if (typeof maybeCount === 'number' && Number.isFinite(maybeCount)) { + return maybeCount; + } + } + + return 0; +} + +async function loadOverviewData(apiServiceInstance: ApiService): Promise<{ + deviceStatuses: DeviceStatusSummary; + triggeredRules: RuleDto[]; + triggeredRulesCount: number; +}> { + const [deviceStatusesResult, triggeredRulesResult, triggeredRulesCountResult] = + await Promise.allSettled([ + apiServiceInstance.getDeviceStatuses(), + apiServiceInstance.getTriggeredRules(), + apiServiceInstance.getTriggeredRulesCount() + ]); + + if (deviceStatusesResult.status === 'rejected') { + console.error('Failed to fetch overview device statuses:', deviceStatusesResult.reason); + } + + if (triggeredRulesResult.status === 'rejected') { + console.error('Failed to fetch overview triggered rules:', triggeredRulesResult.reason); + } + + if (triggeredRulesCountResult.status === 'rejected') { + console.error( + 'Failed to fetch overview triggered rule count:', + triggeredRulesCountResult.reason + ); + } -let profile; + return { + deviceStatuses: + deviceStatusesResult.status === 'fulfilled' + ? deviceStatusesResult.value + : EMPTY_DEVICE_STATUSES, + triggeredRules: triggeredRulesResult.status === 'fulfilled' ? triggeredRulesResult.value : [], + triggeredRulesCount: + triggeredRulesCountResult.status === 'fulfilled' + ? readTriggeredRulesCount(triggeredRulesCountResult.value) + : 0 + }; +} + +export const load: LayoutServerLoad = async ({ locals, fetch }) => { + let profile; + let overview = { + deviceStatuses: EMPTY_DEVICE_STATUSES, + triggeredRules: [] as RuleDto[], + triggeredRulesCount: 0 + }; if (locals.jwtString) { const apiServiceInstance = new ApiService({ @@ -11,14 +75,28 @@ let profile; authToken: locals.jwtString }); - profile = await apiServiceInstance.getUserProfile().catch((error) => { - console.error('Failed to fetch authenticated user:', error); - }); + const [profileResult, overviewResult] = await Promise.allSettled([ + apiServiceInstance.getUserProfile(), + loadOverviewData(apiServiceInstance) + ]); + + if (profileResult.status === 'fulfilled') { + profile = profileResult.value; + } else { + console.error('Failed to fetch authenticated user:', profileResult.reason); + } + + if (overviewResult.status === 'fulfilled') { + overview = overviewResult.value; + } else { + console.error('Failed to fetch overview data:', overviewResult.reason); + } } return { session: locals.jwt ?? null, authToken: locals.jwtString ?? null, profile, + overview }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fbb8939c..06482209 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,6 @@ import { createCwToastContext, CwOfflineOverlay, - CwStatusDot, CwToastContainer, type CwSideNavMode } from '@cropwatchdevelopment/cwui'; @@ -17,11 +16,8 @@ import OverviewDrawer from './OverviewDrawer.svelte'; import Sidebar from './Sidebar.svelte'; import { createAppContext, setAppContext } from '$lib/appContext.svelte'; - import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashboard-filter-values'; + import type { DeviceStatusSummary, RuleDto } from '$lib/api/api.dtos'; import type { IJWT } from '$lib/interfaces/jwt.interface'; - import type { IDevice } from '$lib/interfaces/device.interface'; - import type { LocationDto, RuleDto, TriggeredRulesCountResponse } from '$lib/api/api.dtos'; - import type { DeviceTypeLookup } from '$lib/components/dashboard/dashboard-device-data'; import type { LayoutProps } from './$types'; import Header from './Header.svelte'; import type { Profile } from '$lib/interfaces/profile.interface'; @@ -35,40 +31,15 @@ let isOfflineRoute = $derived(page.url.pathname === '/offline'); let resizeTimer: ReturnType | null = null; - interface DashboardPageData { + interface AppLayoutData { session?: IJWT | null; authToken?: string | null; - devices?: IDevice[]; - deviceTypeLookup?: DeviceTypeLookup; - totalDeviceCount?: number; - deviceStatuses?: { online: number; offline: number }; - triggeredRules?: RuleDto[]; - triggeredRulesCount?: TriggeredRulesCountResponse; - deviceGroups?: string[]; - locationGroups?: string[]; - locations?: LocationDto[]; profile?: Profile | undefined; - dashboardDebug?: Record | null; - } - - function readTriggeredRulesCount( - rawTriggeredRulesCount: TriggeredRulesCountResponse | undefined - ): number { - if (typeof rawTriggeredRulesCount === 'number' && Number.isFinite(rawTriggeredRulesCount)) { - return rawTriggeredRulesCount; - } - - if (rawTriggeredRulesCount && typeof rawTriggeredRulesCount === 'object') { - const maybeCount = - (rawTriggeredRulesCount as Record).count ?? - (rawTriggeredRulesCount as Record).triggered_count; - - if (typeof maybeCount === 'number' && Number.isFinite(maybeCount)) { - return maybeCount; - } - } - - return 0; + overview?: { + deviceStatuses: DeviceStatusSummary; + triggeredRules: RuleDto[]; + triggeredRulesCount: number; + }; } const app = $state(createAppContext()); @@ -76,33 +47,14 @@ setAppContext(app); function syncAppFromPageData() { - const routeData = page.data as DashboardPageData; - const isDashboardRoute = page.url.pathname === '/'; + const routeData = page.data as AppLayoutData; app.session = routeData.session ?? null; app.accessToken = routeData.authToken ?? undefined; app.profile = routeData.profile ?? undefined; - - if (!isDashboardRoute) { - return; - } - - // Dashboard data is streamed via +page.server.ts → +page.svelte handles the sync - if ('dashboard' in routeData) { - return; - } - - const devices = routeData.devices ?? []; - - app.devices = devices; - app.deviceTypeLookup = routeData.deviceTypeLookup ?? { byModel: {}, idToModel: {} }; - app.totalDeviceCount = routeData.totalDeviceCount ?? devices.length; - app.deviceStatuses = routeData.deviceStatuses ?? { online: 0, offline: 0 }; - app.triggeredRules = routeData.triggeredRules ?? []; - app.triggeredRulesCount = readTriggeredRulesCount(routeData.triggeredRulesCount); - app.deviceGroups = normalizeDashboardFilterValues(routeData.deviceGroups); - app.locationGroups = normalizeDashboardFilterValues(routeData.locationGroups); - app.locations = routeData.locations ?? []; + app.deviceStatuses = routeData.overview?.deviceStatuses ?? { online: 0, offline: 0 }; + app.triggeredRules = routeData.overview?.triggeredRules ?? []; + app.triggeredRulesCount = routeData.overview?.triggeredRulesCount ?? 0; } syncAppFromPageData(); @@ -192,7 +144,7 @@
\ No newline at end of file + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 84a0a4e6..588ab46c 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,13 +1,8 @@ import { env as publicEnv } from '$env/dynamic/public'; -import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import { ApiService } from '$lib/api/api.service'; import type { DevicePrimaryDataDto, LocationDto } from '$lib/api/api.dtos'; import type { IDevice } from '$lib/interfaces/device.interface'; -import { - buildDeviceTypeLookup, - mapDashboardDeviceMetadataToDevice, - mapDashboardPrimaryDataToDevice, - mergeDashboardDevices -} from '$lib/components/dashboard/dashboard-device-data'; +import { mapDashboardPrimaryDataToDevice } from '$lib/components/dashboard/dashboard-device-data'; import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashboard-filter-values'; import type { PageServerLoad } from './$types'; @@ -18,28 +13,6 @@ function resolveDashboardApiBaseUrl(url: URL): string { return configuredBaseUrl && configuredBaseUrl.length > 0 ? configuredBaseUrl : url.origin; } -function serializeError(error: unknown): Record { - if (error instanceof ApiServiceError) { - return { - name: error.name, - message: error.message, - status: error.status, - payload: error.payload - }; - } - - if (error instanceof Error) { - return { - name: error.name, - message: error.message - }; - } - - return { - message: String(error) - }; -} - async function getAllLatestPrimaryDeviceData( apiServiceInstance: ApiService ): Promise { @@ -75,176 +48,43 @@ async function getAllLatestPrimaryDeviceData( return allDevices; } -function buildDerivedLocations(latestPrimaryData: DevicePrimaryDataDto[]): LocationDto[] { - const locationMap = new Map(); - - for (const device of latestPrimaryData) { - const locationId = Number(device.location_id); - const locationName = device.location_name?.trim() ?? ''; - - if (!Number.isFinite(locationId) || locationId <= 0 || !locationName) { - continue; - } - - if (!locationMap.has(locationId)) { - locationMap.set(locationId, locationName); - } - } - - return Array.from(locationMap.entries()) - .sort( - ([leftId, leftName], [rightId, rightName]) => - leftName.localeCompare(rightName) || leftId - rightId - ) - .map( - ([location_id, name]) => - ({ - location_id, - name, - created_at: new Date(0).toISOString() - }) as LocationDto - ); -} - -async function loadDashboardLatestPrimaryData(apiServiceInstance: ApiService): Promise<{ - value: DevicePrimaryDataDto[]; - error: Record | null; - source: 'bulk' | 'empty'; - skippedDevEuis: string[]; -}> { +async function loadDashboardList( + label: string, + loadList: () => Promise +): Promise { try { - return { - value: await getAllLatestPrimaryDeviceData(apiServiceInstance), - error: null, - source: 'bulk', - skippedDevEuis: [] - }; - } catch (bulkError) { - console.error('Failed to load latest primary data', bulkError); - return { - value: [], - error: serializeError(bulkError), - source: 'empty', - skippedDevEuis: [] - }; + return (await loadList()) ?? []; + } catch (error) { + console.error(`Failed to load dashboard ${label}`, error); + return []; } } async function loadDashboardPayload(apiServiceInstance: ApiService) { - const [ - latestPrimaryDataResult, - devicesResult, - deviceTypesResult, - triggeredRules, - triggeredRulesCount, - deviceStatusesResult, - deviceGroupsResult, - locationsResult, - locationGroupsResult - ] = await Promise.all([ - loadDashboardLatestPrimaryData(apiServiceInstance), - apiServiceInstance - .getAllDevices() - .then((value) => ({ value: value ?? [], error: null as Record | null })) - .catch((error) => ({ value: [], error: serializeError(error) })), - apiServiceInstance - .getDeviceTypes() - .then((value) => ({ value: value ?? [], error: null as Record | null })) - .catch((error) => ({ value: [], error: serializeError(error) })), - apiServiceInstance.getTriggeredRules().catch(() => []), - apiServiceInstance.getTriggeredRulesCount().catch(() => 0), - apiServiceInstance - .getDeviceStatuses() - .then((value) => ({ value, error: null as Record | null })) - .catch((error) => ({ - value: { online: 0, offline: 0 }, - error: serializeError(error) - })), - apiServiceInstance - .getDeviceGroups() - .then((value) => ({ value: value ?? [], error: null as Record | null })) - .catch((error) => ({ value: [], error: serializeError(error) })), - apiServiceInstance - .getLocations() - .then((value) => ({ value, error: null as Record | null })) - .catch((error) => ({ value: [], error: serializeError(error) })), - apiServiceInstance - .getLocationGroups() - .then((value) => ({ value, error: null as Record | null })) - .catch((error) => ({ value: [], error: serializeError(error) })) + const [latestPrimaryData, deviceGroups, locations, locationGroups] = await Promise.all([ + await getAllLatestPrimaryDeviceData(apiServiceInstance), + loadDashboardList('device groups', () => apiServiceInstance.getDeviceGroups()), + loadDashboardList('locations', () => apiServiceInstance.getLocations()), + loadDashboardList('location groups', () => apiServiceInstance.getLocationGroups()) ]); - const latestPrimaryData = latestPrimaryDataResult.value; - const allDevices = devicesResult.value; - const deviceStatuses = deviceStatusesResult.value; - const locations = locationsResult.value; - const locationGroups = normalizeDashboardFilterValues(locationGroupsResult.value); - - // ── Build device type lookup from dedicated endpoint ── - const deviceTypeLookup = buildDeviceTypeLookup(deviceTypesResult.value); - const metadataDevices = allDevices.map((device) => mapDashboardDeviceMetadataToDevice(device)); - const primaryDataDevices = latestPrimaryData.map((device) => mapDashboardPrimaryDataToDevice(device)); - - const devices: IDevice[] = mergeDashboardDevices(metadataDevices, primaryDataDevices); - - const groups = normalizeDashboardFilterValues(devices.map((device) => device.group)); - const deviceGroups = normalizeDashboardFilterValues(deviceGroupsResult.value); - - const resolvedDeviceGroups = deviceGroups.length > 0 ? deviceGroups : groups; - const derivedLocations = buildDerivedLocations(latestPrimaryData); - const resolvedLocations = locations.length > 0 ? locations : derivedLocations; + const devices: IDevice[] = latestPrimaryData.map((device) => + mapDashboardPrimaryDataToDevice(device) + ); return { devices, - deviceTypeLookup, - totalDeviceCount: devices.length, - triggeredRules, - triggeredRulesCount, - deviceStatuses, - deviceGroups: resolvedDeviceGroups, - locations: resolvedLocations, - locationGroups, - dashboardDebug: { - latestPrimaryData: { - count: latestPrimaryData.length, - error: latestPrimaryDataResult.error, - source: latestPrimaryDataResult.source, - skippedDevEuis: latestPrimaryDataResult.skippedDevEuis - }, - deviceStatuses: { - value: deviceStatuses, - error: deviceStatusesResult.error - }, - deviceGroups: { - count: resolvedDeviceGroups.length, - error: deviceGroupsResult.error - }, - devices: { - count: devices.length, - error: devicesResult.error - }, - locations: { - count: resolvedLocations.length, - error: locationsResult.error - }, - locationGroups: { - count: locationGroups.length, - error: locationGroupsResult.error - } - } + deviceGroups: normalizeDashboardFilterValues(deviceGroups), + locations, + locationGroups: normalizeDashboardFilterValues(locationGroups) }; } const EMPTY_DASHBOARD = { devices: [] as IDevice[], - totalDeviceCount: 0, - triggeredRules: [] as unknown[], - triggeredRulesCount: 0, - deviceStatuses: { online: 0, offline: 0 }, deviceGroups: [] as string[], locations: [] as LocationDto[], - locationGroups: [] as string[], - dashboardDebug: null as Record | null + locationGroups: [] as string[] }; export const load: PageServerLoad = async ({ locals, fetch, url }) => { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 19caf1b3..a5ad6c65 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ - - {m.dashboard_page_title()} @@ -167,13 +116,14 @@
-
+
setDashboardView('table')} > @@ -182,6 +132,7 @@ setDashboardView('sensor-cards')} > @@ -190,17 +141,19 @@ {#if dashboardView === 'sensor-cards'}
- {#if dashboardLoading} -
-
-
- -
- - {m.dashboard_loading_devices()} - -
-
- {:else if dashboardViewReady} + {#if dashboardViewReady} {#if dashboardView === 'sensor-cards'} {:else} @@ -236,3 +178,18 @@ {/if}
+ + diff --git a/src/routes/OverviewDrawer.svelte b/src/routes/OverviewDrawer.svelte index 15267427..5a4b5f6a 100644 --- a/src/routes/OverviewDrawer.svelte +++ b/src/routes/OverviewDrawer.svelte @@ -45,13 +45,15 @@ { label: m.status_offline(), value: offlineDevices, color: appChartPalette.danger }, { label: m.status_alerts(), value: alertCount, color: appChartPalette.warning } ]); - const triggeredRules = Array.isArray(app?.triggeredRules) ? app.triggeredRules : []; - const alerts: AlertRow[] = triggeredRules.map((rule) => ({ - id: String(rule.id), - icon: '🔔', - name: rule.name, - reported: rule.last_triggered ? formatDateTime(rule.last_triggered) : m.common_not_available() - })); + let triggeredRules = $derived(Array.isArray(app?.triggeredRules) ? app.triggeredRules : []); + let alerts = $derived( + triggeredRules.map((rule) => ({ + id: String(rule.id), + icon: '🔔', + name: rule.name, + reported: rule.last_triggered ? formatDateTime(rule.last_triggered) : m.common_not_available() + })) + ); { - const context = requireRuleTemplateRequestContext(event); - if (context instanceof Response) return context; - - try { - const search = event.url.searchParams.get('search') ?? undefined; - return json(await listRuleTemplatesForUser(context, { search })); - } catch (error) { - return repositoryErrorResponse(error, m.rules_new_load_failed()); - } -}; - -export const POST: RequestHandler = async (event) => { - const context = requireRuleTemplateRequestContext(event); - if (context instanceof Response) return context; - - try { - const payload = await event.request.json(); - const rule = await createRuleTemplateForUser(context, payload); - return json(rule, { status: 201 }); - } catch (error) { - return repositoryErrorResponse(error, m.rules_new_save_failed()); - } -}; diff --git a/src/routes/api/rules-new/[id]/+server.ts b/src/routes/api/rules-new/[id]/+server.ts deleted file mode 100644 index f3e054f9..00000000 --- a/src/routes/api/rules-new/[id]/+server.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - deleteRuleTemplateForUser, - getRuleTemplateForUser, - updateRuleTemplateForUser -} from '$lib/server/rules-new/rule-template.repository'; -import { - parseRuleTemplateId, - repositoryErrorResponse, - requireRuleTemplateRequestContext -} from '$lib/server/rules-new/route-helpers'; -import { m } from '$lib/paraglide/messages.js'; -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async (event) => { - const context = requireRuleTemplateRequestContext(event); - if (context instanceof Response) return context; - - const templateId = parseRuleTemplateId(event.params.id); - if (templateId instanceof Response) return templateId; - - try { - return json(await getRuleTemplateForUser(context, templateId)); - } catch (error) { - return repositoryErrorResponse(error, m.rules_new_load_failed()); - } -}; - -export const PATCH: RequestHandler = async (event) => { - const context = requireRuleTemplateRequestContext(event); - if (context instanceof Response) return context; - - const templateId = parseRuleTemplateId(event.params.id); - if (templateId instanceof Response) return templateId; - - try { - const payload = await event.request.json(); - return json(await updateRuleTemplateForUser(context, templateId, payload)); - } catch (error) { - return repositoryErrorResponse(error, m.rules_new_save_failed()); - } -}; - -export const DELETE: RequestHandler = async (event) => { - const context = requireRuleTemplateRequestContext(event); - if (context instanceof Response) return context; - - const templateId = parseRuleTemplateId(event.params.id); - if (templateId instanceof Response) return templateId; - - try { - return json(await deleteRuleTemplateForUser(context, templateId)); - } catch (error) { - return repositoryErrorResponse(error, m.rules_new_delete_failed()); - } -}; diff --git a/src/routes/auth/create-account/+page.svelte b/src/routes/auth/create-account/+page.svelte index 1b570fb7..44edec91 100644 --- a/src/routes/auth/create-account/+page.svelte +++ b/src/routes/auth/create-account/+page.svelte @@ -13,6 +13,8 @@ import { readRedirectPath } from '$lib/utils/auth-redirect'; import { CwButton, CwCard, CwInput, useCwToast } from '@cropwatchdevelopment/cwui'; import { m } from '$lib/paraglide/messages.js'; + import CreateAccountPasswordFields from './CreateAccountPasswordFields.svelte'; + import CreateAccountConsentFields from './CreateAccountConsentFields.svelte'; interface Props { form: { @@ -42,28 +44,6 @@ let agreedPrivacy: boolean = $state(false); let agreedCookies: boolean = $state(false); - /** - * Normalize Unicode to NFKC so that full-width digits, accented composites, - * etc. are folded into their ASCII / composed equivalents before we evaluate - * the password. For example, a full-width "1" (U+FF11) becomes "1". - */ - function normalizeInput(value: string): string { - return value.normalize('NFKC'); - } - - function handlePasswordInput(e: Event) { - const target = e.currentTarget as HTMLInputElement; - password = normalizeInput(target.value); - target.value = password; - } - - function handleConfirmPasswordInput(e: Event) { - const target = e.currentTarget as HTMLInputElement; - confirmPassword = normalizeInput(target.value); - target.value = confirmPassword; - } - - // ── derived password-strength state ──────────────────────── let passwordValidation: IPasswordValidationResult = $derived(isStrongPassword(password)); let passwordsMatch: boolean = $derived( @@ -87,6 +67,15 @@ onMount(() => { void recaptcha.warmup(); }); + + function authPath(path: '/auth/login'): string { + return `${resolve(path)}${redirectPath ? `?redirect=${encodeURIComponent(redirectPath)}` : ''}`; + } + + function navigateWithRedirect(event: MouseEvent, path: '/auth/login') { + event.preventDefault(); + window.location.assign(authPath(path)); + } @@ -102,12 +91,10 @@

{m.auth_create_account()}

{m.auth_or_prefix()} - navigateWithRedirect(event, '/auth/login')} > {m.auth_sign_in_existing_account()} @@ -136,7 +123,6 @@ return; } - // Inject consent flags (hidden inputs would work too but this is cleaner) formData.set('agreedTerms', String(agreedTerms)); formData.set('agreedPrivacy', String(agreedPrivacy)); formData.set('agreedCookies', String(agreedCookies)); @@ -162,7 +148,6 @@ }; }} > -

- - - - - - {#if password.length > 0} -
    -
  • - {passwordValidation.hasMinLength ? '✓' : '○'} - {m.auth_password_requirement_length()} -
  • -
  • - {passwordValidation.hasLowerCase ? '✓' : '○'} - {m.auth_password_requirement_lowercase()} -
  • -
  • - {passwordValidation.hasUpperCase ? '✓' : '○'} - {m.auth_password_requirement_uppercase()} -
  • -
  • - {passwordValidation.hasNumber ? '✓' : '○'} - {m.auth_password_requirement_number()} -
  • -
  • - {passwordValidation.hasSymbol ? '✓' : '○'} - {m.auth_password_requirement_symbol()} -
  • -
- {/if} - - - + - {#if confirmPassword.length > 0} -

- {passwordsMatch ? m.auth_passwords_match() : m.auth_passwords_do_not_match()} -

- {/if} - - - - + - - navigateWithRedirect(event, '/auth/login')} > {m.auth_sign_in_instead()} @@ -382,7 +262,6 @@ cursor: pointer; } - /* ── Name row (side-by-side on wider screens) ──────────── */ .name-row { display: grid; grid-template-columns: 1fr 1fr; @@ -394,86 +273,4 @@ grid-template-columns: 1fr; } } - - /* ── Password strength checklist ───────────────────────── */ - .pw-checklist { - list-style: none; - padding: 0; - margin: 0; - display: grid; - gap: 0.25rem; - font-size: 0.84rem; - color: rgb(170 185 210); - } - - .pw-checklist li { - display: flex; - align-items: center; - gap: 0.4rem; - transition: color 0.15s; - } - - .pw-checklist li.met { - color: rgb(72 220 170); - } - - .pw-icon { - display: inline-flex; - width: 1rem; - justify-content: center; - font-weight: 600; - } - - /* ── Password match hint ───────────────────────────────── */ - .pw-match-hint { - margin: 0; - font-size: 0.84rem; - transition: color 0.15s; - } - - .pw-match-ok { - color: rgb(72 220 170); - } - - .pw-match-fail { - color: rgb(255 140 140); - } - - /* ── Consent checkboxes ────────────────────────────────── */ - .consent-group { - border: 1px solid rgb(58 84 126 / 60%); - border-radius: 0.8rem; - padding: 0.9rem 1rem; - margin: 0; - display: grid; - gap: 0.6rem; - } - - .consent-group legend { - padding: 0 0.4rem; - } - - .consent-item { - display: flex; - align-items: flex-start; - gap: 0.55rem; - font-size: 0.92rem; - color: rgb(200 215 235); - cursor: pointer; - } - - .consent-checkbox { - margin-top: 0.18rem; - width: 1.1rem; - height: 1.1rem; - accent-color: rgb(56 137 203); - cursor: pointer; - flex-shrink: 0; - } - - .consent-hint { - margin: 0; - font-size: 0.82rem; - color: rgb(255 200 120); - } diff --git a/src/routes/auth/create-account/CreateAccountConsentFields.svelte b/src/routes/auth/create-account/CreateAccountConsentFields.svelte new file mode 100644 index 00000000..adc765ce --- /dev/null +++ b/src/routes/auth/create-account/CreateAccountConsentFields.svelte @@ -0,0 +1,103 @@ + + + + + diff --git a/src/routes/auth/create-account/CreateAccountPasswordFields.svelte b/src/routes/auth/create-account/CreateAccountPasswordFields.svelte new file mode 100644 index 00000000..6e642de3 --- /dev/null +++ b/src/routes/auth/create-account/CreateAccountPasswordFields.svelte @@ -0,0 +1,138 @@ + + + + +{#if password.length > 0} +
    +
  • + {passwordValidation.hasMinLength ? '✓' : '○'} + {m.auth_password_requirement_length()} +
  • +
  • + {passwordValidation.hasLowerCase ? '✓' : '○'} + {m.auth_password_requirement_lowercase()} +
  • +
  • + {passwordValidation.hasUpperCase ? '✓' : '○'} + {m.auth_password_requirement_uppercase()} +
  • +
  • + {passwordValidation.hasNumber ? '✓' : '○'} + {m.auth_password_requirement_number()} +
  • +
  • + {passwordValidation.hasSymbol ? '✓' : '○'} + {m.auth_password_requirement_symbol()} +
  • +
+{/if} + + + +{#if confirmPassword.length > 0} +

+ {passwordsMatch ? m.auth_passwords_match() : m.auth_passwords_do_not_match()} +

+{/if} + + diff --git a/src/routes/auth/forgot-password/+page.svelte b/src/routes/auth/forgot-password/+page.svelte index 17e5edbf..568aec27 100644 --- a/src/routes/auth/forgot-password/+page.svelte +++ b/src/routes/auth/forgot-password/+page.svelte @@ -1,5 +1,6 @@ @@ -45,12 +55,10 @@

{m.auth_check_email_heading()}

{m.auth_forgot_password_sent_body()}

- navigateWithRedirect(event, '/auth/login')} > {m.auth_back_to_login()} @@ -121,13 +129,11 @@ {/if}
- - {/if}

{m.auth_security_copy()}

- - diff --git a/src/routes/auth/update-password/+page.svelte b/src/routes/auth/update-password/+page.svelte index 684245a9..7462b7a9 100644 --- a/src/routes/auth/update-password/+page.svelte +++ b/src/routes/auth/update-password/+page.svelte @@ -104,7 +104,7 @@ diff --git a/src/routes/gateways/+page.svelte b/src/routes/gateways/+page.svelte index 68cdc749..06d3996b 100644 --- a/src/routes/gateways/+page.svelte +++ b/src/routes/gateways/+page.svelte @@ -67,7 +67,9 @@ {/if} {:else if col.key === 'updated_at'} - {formatDateTime(row.updated_at, undefined, m.common_not_available())} + {row.updated_at + ? formatDateTime(row.updated_at, undefined, m.common_not_available()) + : m.common_not_available()} {:else} {defaultValue} {/if} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte index 96eba7c3..c9d7e39b 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -1,9 +1,6 @@ + + goto(resolve('/locations/[location_id]', { location_id: locationId }))} +> + ← {m.devices_back_to_location()} + + + + {#snippet actions()} +

+ {m.display_last_updated()}: + {#if lastUpdatedAt} + + {:else} + {m.common_not_available()} + {/if} +

+ {/snippet} + +
+
+ {#if !isTrafficDevice} + {#each rangeOptions as range (range.value)} + onSelectRange(range.value)} + > + {range.label} + + {/each} + {/if} +
+ +
+ {#if permissionLevel <= 2} + + + + + {#if isTrafficDevice} + + {/if} + {/if} + + {#if permissionLevel === 1} + + goto( + resolve('/locations/[location_id]/devices/[dev_eui]/settings', { + location_id: locationId, + dev_eui: devEui + }) + )} + > + + {m.nav_settings()} + + {/if} +
+
+ + {#if fetching} +
+
+ + {m.devices_loading_telemetry()} +
+
+ {/if} +
+ + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceOwnerPermissionsCard.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceOwnerPermissionsCard.svelte new file mode 100644 index 00000000..3812e604 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceOwnerPermissionsCard.svelte @@ -0,0 +1,187 @@ + + + + {#if permissionRows.length === 0} +

{m.devices_no_device_owners()}

+ {:else} +
+ {#each permissionRows as row, index (row.key)} + {@const ownerForm = ownerFormFor(row.key)} + {#if row.email && row.email !== ''} + {#if !row.email.includes('@cropwatch.io') || app.session?.email.includes('@cropwatch.io')} +
+
{ + if (!isOwnerRowValid(row)) { + cancel(); + return; + } + + const key = String(formData.get('ownerKey') ?? row.key); + setOwnerSubmitting(key, true); + + return async ({ update }) => { + try { + await update({ reset: false }); + } finally { + setOwnerSubmitting(key, false); + } + }; + }} + > + + + + +
+ +

{row.name} ({row.email})

+
+ +
+
+ row.permissionLevel, + (value) => updateOwnerPermissionLevel(row.key, String(value ?? '')) + } + error={ownerForm?.fieldErrors?.permissionLevel || + (!isOwnerRowValid(row) + ? m.devices_choose_valid_permission_level() + : undefined)} + /> + {#if ownerForm?.fieldErrors?.permissionLevel} +

{ownerForm.fieldErrors.permissionLevel}

+ {/if} +
+ + + {m.devices_update_permission()} + +
+ + {#if ownerForm?.fieldErrors?.targetUserEmail} +

+ {ownerForm.fieldErrors.targetUserEmail} +

+ {/if} + + {#if ownerForm?.message} + + {/if} +
+
+ {#if index < permissionRows.length - 1} + + {/if} + {/if} + {/if} + {/each} +
+ {/if} +
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte new file mode 100644 index 00000000..ef0329af --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte @@ -0,0 +1,124 @@ + + + + {#if !hasSensorCertificates} +

{m.devices_no_sensor_serial()}

+ {:else} +
+ {#if sensorOneCertificate} +
+
+
+ + + {#if sensorOneCertificate.product} + + {/if} +
+ +

{m.devices_sensor_certificate_note()}

+ + {#if sensorOneCertificate.downloadDisabledReason} +

{sensorOneCertificate.downloadDisabledReason}

+ {/if} +
+ +
+ + + +
+
+ {/if} + + {#if sensorOneCertificate && sensorTwoCertificate} + + {/if} + + {#if sensorTwoCertificate} +
+
+
+ + + {#if sensorTwoCertificate.product} + + {/if} +
+ +

{m.devices_sensor_certificate_note()}

+
+ +
+ + + +
+
+ {/if} +
+ {/if} +
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte index 2e961fa4..ceccbee7 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte @@ -1,12 +1,12 @@ (open = true)}> - - Notes - \ No newline at end of file + + Notes +
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts index df4e9cf3..ff75e548 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts @@ -22,7 +22,18 @@ interface DownloadCsvOptions { const CREATED_AT_KEY = 'created_at'; const TIMESTAMP_COLUMN_NAMES = new Set(['created_at', 'timestamp', 'traffic_hour']); -const CSV_ALLOWED_COLUMNS = [CREATED_AT_KEY, 'co2', 'humidity', 'temperature_c', 'moisture', 'ec', 'traffic_day', 'total_people', 'total_bicycles', 'total_vehicles']; +const CSV_ALLOWED_COLUMNS = [ + CREATED_AT_KEY, + 'co2', + 'humidity', + 'temperature_c', + 'moisture', + 'ec', + 'traffic_day', + 'total_people', + 'total_bicycles', + 'total_vehicles' +]; const MILLISECONDS_PER_MINUTE = 60_000; const TIMEZONE_SUFFIX_PATTERN = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/; @@ -238,6 +249,15 @@ function padDatePart(value: number, size = 2): string { return String(value).padStart(size, '0'); } +function formatTimeZoneOffset(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? '+' : '-'; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteMinutes / 60); + const minutes = absoluteMinutes % 60; + + return `${sign}${padDatePart(hours)}:${padDatePart(minutes)}`; +} + function normalizeTimestampInput(value: string): string { const trimmed = value.trim(); if (!trimmed) return trimmed; @@ -286,7 +306,6 @@ function getTimeZoneOffsetMilliseconds(date: Date, timeZone: string): number { return asUtc - date.getTime(); } - function formatDateForTimeZone(date: Date, timeZone: string): string { const offsetMinutes = Math.round( getTimeZoneOffsetMilliseconds(date, timeZone) / MILLISECONDS_PER_MINUTE @@ -295,7 +314,9 @@ function formatDateForTimeZone(date: Date, timeZone: string): string { const datePart = `${shiftedDate.getUTCFullYear()}-${padDatePart(shiftedDate.getUTCMonth() + 1)}-${padDatePart(shiftedDate.getUTCDate())}`; const timePart = `${padDatePart(shiftedDate.getUTCHours())}:${padDatePart(shiftedDate.getUTCMinutes())}:${padDatePart(shiftedDate.getUTCSeconds())}`; - return `${datePart} ${timePart}`; + const milliseconds = padDatePart(shiftedDate.getUTCMilliseconds(), 3); + + return `${datePart}T${timePart}.${milliseconds}${formatTimeZoneOffset(offsetMinutes)}`; } function formatFileDateTime(date: Date, timeZone: string): string { diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts new file mode 100644 index 00000000..e6ff6ef5 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts @@ -0,0 +1,167 @@ +import { ApiServiceError } from '$lib/api/api.service'; +import type { RelayStateSnapshot, RelayVerificationResult } from '$lib/devices/relay-control'; +import { getRelayState, normalizeRelayTelemetryRow } from '$lib/devices/relay-telemetry'; +import type { RelayNumber, RelayTargetState } from '$lib/devices/relay-types'; +import { m } from '$lib/paraglide/messages.js'; +import { SvelteDate } from 'svelte/reactivity'; + +export type RangeSelection = 'today' | 24 | 48 | 72; +export type TelemetryRow = Record; + +export interface TimeRangeOptions { + label: string; + value: RangeSelection; +} + +export interface RouteState { + requestedHistoricalData: TelemetryRow[] | null; + activeRange: RangeSelection | null; + fetching: boolean; + fetchError: string | null; +} + +const MILLISECONDS_PER_HOUR = 60 * 60 * 1000; +export const DEFAULT_RANGE_SELECTION: RangeSelection = 'today'; +export const MAX_RANGE_RECORDS = 1500; + +export function isTelemetryRow(value: unknown): value is TelemetryRow { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function normalizeTelemetryRows(value: unknown): TelemetryRow[] { + if (Array.isArray(value)) { + return value.filter(isTelemetryRow); + } + + if (isTelemetryRow(value) && Array.isArray(value.data)) { + return value.data.filter(isTelemetryRow); + } + + return []; +} + +export function readLocationName(value: unknown): string { + if (!isTelemetryRow(value)) { + return m.devices_unknown_location(); + } + + const locationRecord = value.cw_locations; + if ( + isTelemetryRow(locationRecord) && + typeof locationRecord.name === 'string' && + locationRecord.name.trim() + ) { + return locationRecord.name; + } + + if (typeof value.location_name === 'string' && value.location_name.trim()) { + return value.location_name; + } + + return m.devices_unknown_location(); +} + +export function readCreatedAt(value: TelemetryRow | null | undefined): string | null { + return value && typeof value.created_at === 'string' ? value.created_at : null; +} + +export function readRelayTelemetryRow(value: unknown): TelemetryRow | null { + const normalizedRow = normalizeRelayTelemetryRow(value); + if (normalizedRow) { + return normalizedRow; + } + + if (isTelemetryRow(value)) { + const nestedRow = normalizeRelayTelemetryRow(value.data); + if (nestedRow) { + return nestedRow; + } + } + + const [firstRow] = normalizeTelemetryRows(value); + return firstRow ?? null; +} + +export function getRangeOptions(): TimeRangeOptions[] { + return [ + { label: m.devices_range_today_only(), value: DEFAULT_RANGE_SELECTION }, + { label: m.devices_range_last_hours({ hours: '24' }), value: 24 }, + { label: m.devices_range_last_hours({ hours: '48' }), value: 48 }, + { label: m.devices_range_last_hours({ hours: '72' }), value: 72 } + ]; +} + +export function toIsoString(value: Date | string): string { + return new Date(value).toISOString(); +} + +export function createRouteState(): RouteState { + return { + requestedHistoricalData: null, + activeRange: null, + fetching: false, + fetchError: null + }; +} + +export function createEmptyRelaySnapshot(): RelayStateSnapshot { + return { + historicalData: [], + latestData: null, + pendingRelayStates: {} + }; +} + +export function getRangeBounds(selection: RangeSelection): { start: string; end: string } { + const end = new SvelteDate(); + const endTime = end.getTime(); + if (selection === 'today') { + const start = new SvelteDate(endTime); + start.setHours(0, 0, 0, 0); + return { + start: start.toISOString(), + end: end.toISOString() + }; + } + + return { + start: new SvelteDate(endTime - selection * MILLISECONDS_PER_HOUR).toISOString(), + end: end.toISOString() + }; +} + +export function getRelayTargetStateLabel(targetState: RelayTargetState): string { + return targetState === 'on' ? m.display_on() : m.display_off(); +} + +export function getVerifiedRelayStateLabel( + relay: RelayNumber, + row: Record | null +): string { + const value = getRelayState(normalizeRelayTelemetryRow(row), relay); + if (value === null) { + return m.display_unknown(); + } + + return value ? m.display_on() : m.display_off(); +} + +export function mapRelayApiErrorMessage(error: unknown): string { + if (!(error instanceof ApiServiceError)) { + return m.devices_relay_command_failed(); + } + + switch (error.status) { + case 401: + case 403: + return m.devices_relay_downlink_not_authorized(); + case 404: + return m.devices_relay_downlink_target_not_found(); + case 429: + return m.devices_relay_downlink_rate_limited(); + default: + return m.devices_relay_command_failed(); + } +} + +export type RelayConfirmationHandler = (result: RelayVerificationResult) => void; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts index 29832cfc..354f0e80 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts @@ -1,181 +1,23 @@ -import { env } from '$env/dynamic/private'; -import { - ApiService, - ApiServiceError, - type DeviceDto, - type LocationDto -} from '$lib/api/api.service'; +import { ApiService, ApiServiceError } from '$lib/api/api.service'; import { readApiErrorMessage } from '$lib/api/api-error'; -import { - isValidTtiDeviceId, - normalizeTtiDeviceId, - TTI_DEVICE_ID_MAX_LENGTH -} from '$lib/devices/tti-device-id'; +import { normalizeTtiDeviceId } from '$lib/devices/tti-device-id'; import { m } from '$lib/paraglide/messages.js'; import { fail, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; - -const DEVICE_NAME_MAX_LENGTH = 120; -const DEVICE_GROUP_MAX_LENGTH = 120; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const VALID_PERMISSION_LEVELS = new Set([1, 2, 3, 4]); - -type DeviceFormValues = { - name: string; - group: string; - location_id: number; - tti_name: string; -}; - -type DeviceOwnerPermissionValues = { - ownerKey: string; - targetUserEmail: string; - permissionLevel: string; -}; - -type OwnerIdentity = { - email: string; - name: string; -}; - -type NormalizedDeviceOwner = { - id: number; - key: string; - name: string; - email: string; - userId: string; - permissionLevel: number; -}; - -type SensorCertificateRow = { - key: 'sensor' | 'sensor2'; - label: string; - serial: string; - product: string; - downloadDisabledReason: string | null; -}; - -const readString = (value: FormDataEntryValue | null): string => { - if (typeof value !== 'string') return ''; - return value.trim(); -}; - - -function str(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function readProfileIdentity(record: Record): OwnerIdentity { - const profiles = record.profiles as Record | undefined; - return { - email: str(record.email) || str(record.user_email) || str(profiles?.email), - name: - str(record.name) || - str(record.full_name) || - str(profiles?.full_name) || - str(profiles?.display_name) - }; -} - -function buildLocationOwnerIdentityMap(location: LocationDto | null): Map { - const identities = new Map(); - const owners = location?.cw_location_owners ?? []; - - for (const owner of owners) { - const userId = typeof owner.user_id === 'string' ? owner.user_id.trim() : ''; - if (!userId) continue; - - const existing = identities.get(userId) ?? { email: '', name: '' }; - const profile = readProfileIdentity(owner as Record); - identities.set(userId, { - email: profile.email || existing.email, - name: profile.name || existing.name - }); - } - - return identities; -} - -function normalizeDeviceOwners( - device: DeviceDto | null, - locationOwnerIdentities: Map -): NormalizedDeviceOwner[] { - const owners = device?.cw_device_owners ?? []; - - return owners - .map((owner, index) => { - const userId = str(owner.user_id); - const locationOwnerIdentity = locationOwnerIdentities.get(userId); - const ownerIdentity = readProfileIdentity(owner); - const email = - ownerIdentity.email || str(owner.targetUserEmail) || locationOwnerIdentity?.email || ''; - const name = - ownerIdentity.name || - locationOwnerIdentity?.name || - userId || - m.devices_owner_fallback_name({ index: String(index + 1) }); - const rawId = owner.id; - const id = typeof rawId === 'number' && Number.isFinite(rawId) ? rawId : index + 1; - const rawPerm = owner.permission_level; - const permissionLevel = typeof rawPerm === 'number' && Number.isFinite(rawPerm) ? rawPerm : 4; - - return { - id, - key: email || userId || String(id), - name, - email, - userId, - permissionLevel - } satisfies NormalizedDeviceOwner; - }) - .sort((left, right) => - `${left.name} ${left.email} ${left.userId}`.localeCompare( - `${right.name} ${right.email} ${right.userId}` - ) - ); -} - -function buildSensorCertificateRows(device: DeviceDto | null): SensorCertificateRow[] { - if (!device) return []; - - const product = str(device.cw_device_type?.model); - const hasApiToken = str(env.PRIVATE_LIBELLUS_API_TOKEN).length > 0; - const hasBaseUrl = str(env.PRIVATE_LIBELLUS_BASE_URL).length > 0; - const record = device as Record; - const rows = [ - { - key: 'sensor' as const, - label: m.devices_sensor_one(), - serial: str(record.sensor1_serial) || str(record.sensor_serial) - }, - { - key: 'sensor2' as const, - label: m.devices_sensor_two(), - serial: str(record.sensor2_serial) - } - ]; - - return rows - .filter((row) => row.serial.length > 0) - .map((row) => ({ - ...row, - product, - downloadDisabledReason: - row.key !== 'sensor' - ? null - : !hasApiToken - ? m.devices_libellus_api_token_missing() - : !hasBaseUrl - ? m.devices_libellus_base_url_missing() - : !product - ? m.devices_libellus_product_name_missing() - : null - })); -} +import { + buildLocationOwnerIdentityMap, + buildSensorCertificateRows, + normalizeDeviceOwners, + readDeviceFormValues, + readDeviceOwnerPermissionValues, + str, + validateDeviceFormValues, + validateDeviceOwnerPermissionValues, + type NormalizedDeviceOwner, + type SensorCertificateRow +} from './device-settings.server'; export const load: PageServerLoad = async ({ fetch, params, parent }) => { - // Get authToken and the already-fetched device from the device layout to - // avoid a duplicate API call — the layout owns device loading for this route tree. const parentData = await parent(); const authToken = parentData.authToken ?? null; const devEui = String(params.dev_eui ?? '').trim(); @@ -194,8 +36,6 @@ export const load: PageServerLoad = async ({ fetch, params, parent }) => { } const api = new ApiService({ fetchFn: fetch, authToken }); - - // Reuse device from the device layout; fetch only what the layout doesn't supply. const device = parentData.device; const [deviceGroups, location, locations] = await Promise.all([ @@ -207,18 +47,16 @@ export const load: PageServerLoad = async ({ fetch, params, parent }) => { ]); const locationOwnerIdentities = buildLocationOwnerIdentityMap(location); - const deviceName = device?.name || devEui.toUpperCase(); - const sensorCertificates = buildSensorCertificateRows(device); return { devEui, - deviceName, + deviceName: device?.name || devEui.toUpperCase(), location_id: device?.location_id, deviceGroup: device?.group || '', ttiName: normalizeTtiDeviceId(str(device?.tti_name)), deviceGroups, locations, - sensorCertificates, + sensorCertificates: buildSensorCertificateRows(device), deviceOwners: normalizeDeviceOwners(device, locationOwnerIdentities) }; }; @@ -242,38 +80,8 @@ export const actions: Actions = { }); } - const formData = await request.formData(); - const values: DeviceFormValues = { - name: readString(formData.get('name')), - group: readString(formData.get('group')), - location_id: Number.parseInt(readString(formData.get('location_id')), 10), - tti_name: normalizeTtiDeviceId(readString(formData.get('tti_name'))) - }; - - const fieldErrors: Partial> = {}; - - if (!values.name) { - fieldErrors.name = m.devices_device_name_required(); - } else if (values.name.length > DEVICE_NAME_MAX_LENGTH) { - fieldErrors.name = m.devices_device_name_length({ max: String(DEVICE_NAME_MAX_LENGTH) }); - } - - if (values.group.length > DEVICE_GROUP_MAX_LENGTH) { - fieldErrors.group = m.devices_device_group_length({ - max: String(DEVICE_GROUP_MAX_LENGTH) - }); - } - - if ( - values.tti_name.length > TTI_DEVICE_ID_MAX_LENGTH || - (values.tti_name.length > 0 && !isValidTtiDeviceId(values.tti_name)) - ) { - fieldErrors.tti_name = m.devices_tti_device_id_invalid(); - } - - if (!values.location_id) { - fieldErrors.location_id = m.devices_location_required(); - } + const values = readDeviceFormValues(await request.formData()); + const fieldErrors = validateDeviceFormValues(values); if (Object.keys(fieldErrors).length > 0) { return fail(400, { @@ -290,11 +98,10 @@ export const actions: Actions = { }); try { - console.log('Updating device with values:', { devEui, ...values }); await api.updateDevice(devEui, { name: values.name, group: values.group || null, - location_id: +values.location_id, + location_id: values.location_id, tti_name: values.tti_name || null }); } catch (error) { @@ -333,26 +140,8 @@ export const actions: Actions = { }); } - const formData = await request.formData(); - const values: DeviceOwnerPermissionValues = { - ownerKey: readString(formData.get('ownerKey')), - targetUserEmail: readString(formData.get('targetUserEmail')), - permissionLevel: readString(formData.get('permissionLevel')) - }; - - const fieldErrors: Partial> = {}; - const permissionLevel = Number.parseInt(values.permissionLevel, 10); - - if (!values.targetUserEmail) { - fieldErrors.targetUserEmail = m.devices_owner_email_required(); - } else if (!EMAIL_PATTERN.test(values.targetUserEmail)) { - fieldErrors.targetUserEmail = m.devices_owner_email_invalid(); - } - - if (!Number.isFinite(permissionLevel) || !VALID_PERMISSION_LEVELS.has(permissionLevel)) { - fieldErrors.permissionLevel = m.devices_choose_valid_permission_level(); - } - + const values = readDeviceOwnerPermissionValues(await request.formData()); + const { fieldErrors, permissionLevel } = validateDeviceOwnerPermissionValues(values); const ownerKey = values.ownerKey || values.targetUserEmail; if (Object.keys(fieldErrors).length > 0) { diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte index 3e977b4f..8a5d18ff 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte @@ -1,37 +1,17 @@ @@ -290,194 +206,11 @@ - - {#if !hasSensorCertificates} -

{m.devices_no_sensor_serial()}

- {:else} -
- {#if sensorOneCertificate} -
-
-
- - - {#if sensorOneCertificate.product} - - {/if} -
- -

{m.devices_sensor_certificate_note()}

- - {#if sensorOneCertificate.downloadDisabledReason} -

{sensorOneCertificate.downloadDisabledReason}

- {/if} -
- -
- - - -
-
- {/if} - - {#if sensorOneCertificate && sensorTwoCertificate} - - {/if} + - {#if sensorTwoCertificate} -
-
-
- - - {#if sensorTwoCertificate.product} - - {/if} -
- -

{m.devices_sensor_certificate_note()}

-
- -
- - - -
-
- {/if} -
- {/if} -
- - - {#if permissionRows.length === 0} -

{m.devices_no_device_owners()}

- {:else} -
- {#each permissionRows as row, index (row.key)} - {@const ownerForm = ownerFormFor(row.key)} - {#if row.email && row.email !== ''} - {#if !row.email.includes('@cropwatch.io') || app.session?.email.includes('@cropwatch.io')} -
-
{ - if (!isOwnerRowValid(row)) { - cancel(); - return; - } - - const key = String(formData.get('ownerKey') ?? row.key); - setOwnerSubmitting(key, true); - - return async ({ update }) => { - try { - await update({ reset: false }); - } finally { - setOwnerSubmitting(key, false); - } - }; - }} - > - - - - -
- - -

{row.name} ({row.email})

-
- -
-
- row.permissionLevel, - (value) => updateOwnerPermissionLevel(row.key, String(value ?? '')) - } - error={ownerForm?.fieldErrors?.permissionLevel || - (!isOwnerRowValid(row) - ? m.devices_choose_valid_permission_level() - : undefined)} - /> - {#if ownerForm?.fieldErrors?.permissionLevel} -

{ownerForm.fieldErrors.permissionLevel}

- {/if} -
- - - {m.devices_update_permission()} - -
- - {#if ownerForm?.fieldErrors?.targetUserEmail} -

- {ownerForm.fieldErrors.targetUserEmail} -

- {/if} - - {#if ownerForm?.message} - - {/if} -
-
- {#if index < permissionRows.length - 1} - - {/if} - {/if} - {/if} - {/each} -
- {/if} -
+
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts new file mode 100644 index 00000000..4810097a --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts @@ -0,0 +1,235 @@ +import { env } from '$env/dynamic/private'; +import type { DeviceDto, LocationDto } from '$lib/api/api.service'; +import { + isValidTtiDeviceId, + normalizeTtiDeviceId, + TTI_DEVICE_ID_MAX_LENGTH +} from '$lib/devices/tti-device-id'; +import { m } from '$lib/paraglide/messages.js'; + +export const DEVICE_NAME_MAX_LENGTH = 120; +export const DEVICE_GROUP_MAX_LENGTH = 120; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const VALID_PERMISSION_LEVELS = new Set([1, 2, 3, 4]); + +export type DeviceFormValues = { + name: string; + group: string; + location_id: number; + tti_name: string; +}; + +export type DeviceOwnerPermissionValues = { + ownerKey: string; + targetUserEmail: string; + permissionLevel: string; +}; + +type OwnerIdentity = { + email: string; + name: string; +}; + +export type NormalizedDeviceOwner = { + id: number; + key: string; + name: string; + email: string; + userId: string; + permissionLevel: number; +}; + +export type SensorCertificateRow = { + key: 'sensor' | 'sensor2'; + label: string; + serial: string; + product: string; + downloadDisabledReason: string | null; +}; + +export const readString = (value: FormDataEntryValue | null): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +export function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readProfileIdentity(record: Record): OwnerIdentity { + const profiles = record.profiles as Record | undefined; + return { + email: str(record.email) || str(record.user_email) || str(profiles?.email), + name: + str(record.name) || + str(record.full_name) || + str(profiles?.full_name) || + str(profiles?.display_name) + }; +} + +export function buildLocationOwnerIdentityMap( + location: LocationDto | null +): Map { + const identities = new Map(); + const owners = location?.cw_location_owners ?? []; + + for (const owner of owners) { + const userId = typeof owner.user_id === 'string' ? owner.user_id.trim() : ''; + if (!userId) continue; + + const existing = identities.get(userId) ?? { email: '', name: '' }; + const profile = readProfileIdentity(owner as Record); + identities.set(userId, { + email: profile.email || existing.email, + name: profile.name || existing.name + }); + } + + return identities; +} + +export function normalizeDeviceOwners( + device: DeviceDto | null, + locationOwnerIdentities: Map +): NormalizedDeviceOwner[] { + const owners = device?.cw_device_owners ?? []; + + return owners + .map((owner, index) => { + const userId = str(owner.user_id); + const locationOwnerIdentity = locationOwnerIdentities.get(userId); + const ownerIdentity = readProfileIdentity(owner); + const email = + ownerIdentity.email || str(owner.targetUserEmail) || locationOwnerIdentity?.email || ''; + const name = + ownerIdentity.name || + locationOwnerIdentity?.name || + userId || + m.devices_owner_fallback_name({ index: String(index + 1) }); + const rawId = owner.id; + const id = typeof rawId === 'number' && Number.isFinite(rawId) ? rawId : index + 1; + const rawPerm = owner.permission_level; + const permissionLevel = typeof rawPerm === 'number' && Number.isFinite(rawPerm) ? rawPerm : 4; + + return { + id, + key: email || userId || String(id), + name, + email, + userId, + permissionLevel + } satisfies NormalizedDeviceOwner; + }) + .sort((left, right) => + `${left.name} ${left.email} ${left.userId}`.localeCompare( + `${right.name} ${right.email} ${right.userId}` + ) + ); +} + +export function buildSensorCertificateRows(device: DeviceDto | null): SensorCertificateRow[] { + if (!device) return []; + + const product = str(device.cw_device_type?.model); + const hasApiToken = str(env.PRIVATE_LIBELLUS_API_TOKEN).length > 0; + const hasBaseUrl = str(env.PRIVATE_LIBELLUS_BASE_URL).length > 0; + const record = device as Record; + const rows = [ + { + key: 'sensor' as const, + label: m.devices_sensor_one(), + serial: str(record.sensor1_serial) || str(record.sensor_serial) + }, + { + key: 'sensor2' as const, + label: m.devices_sensor_two(), + serial: str(record.sensor2_serial) + } + ]; + + return rows + .filter((row) => row.serial.length > 0) + .map((row) => ({ + ...row, + product, + downloadDisabledReason: + row.key !== 'sensor' + ? null + : !hasApiToken + ? m.devices_libellus_api_token_missing() + : !hasBaseUrl + ? m.devices_libellus_base_url_missing() + : !product + ? m.devices_libellus_product_name_missing() + : null + })); +} + +export function readDeviceFormValues(formData: FormData): DeviceFormValues { + return { + name: readString(formData.get('name')), + group: readString(formData.get('group')), + location_id: Number.parseInt(readString(formData.get('location_id')), 10), + tti_name: normalizeTtiDeviceId(readString(formData.get('tti_name'))) + }; +} + +export function validateDeviceFormValues( + values: DeviceFormValues +): Partial> { + const fieldErrors: Partial> = {}; + + if (!values.name) { + fieldErrors.name = m.devices_device_name_required(); + } else if (values.name.length > DEVICE_NAME_MAX_LENGTH) { + fieldErrors.name = m.devices_device_name_length({ max: String(DEVICE_NAME_MAX_LENGTH) }); + } + + if (values.group.length > DEVICE_GROUP_MAX_LENGTH) { + fieldErrors.group = m.devices_device_group_length({ + max: String(DEVICE_GROUP_MAX_LENGTH) + }); + } + + if ( + values.tti_name.length > TTI_DEVICE_ID_MAX_LENGTH || + (values.tti_name.length > 0 && !isValidTtiDeviceId(values.tti_name)) + ) { + fieldErrors.tti_name = m.devices_tti_device_id_invalid(); + } + + if (!values.location_id) { + fieldErrors.location_id = m.devices_location_required(); + } + + return fieldErrors; +} + +export function readDeviceOwnerPermissionValues(formData: FormData): DeviceOwnerPermissionValues { + return { + ownerKey: readString(formData.get('ownerKey')), + targetUserEmail: readString(formData.get('targetUserEmail')), + permissionLevel: readString(formData.get('permissionLevel')) + }; +} + +export function validateDeviceOwnerPermissionValues(values: DeviceOwnerPermissionValues): { + fieldErrors: Partial>; + permissionLevel: number; +} { + const fieldErrors: Partial> = {}; + const permissionLevel = Number.parseInt(values.permissionLevel, 10); + + if (!values.targetUserEmail) { + fieldErrors.targetUserEmail = m.devices_owner_email_required(); + } else if (!EMAIL_PATTERN.test(values.targetUserEmail)) { + fieldErrors.targetUserEmail = m.devices_owner_email_invalid(); + } + + if (!Number.isFinite(permissionLevel) || !VALID_PERMISSION_LEVELS.has(permissionLevel)) { + fieldErrors.permissionLevel = m.devices_choose_valid_permission_level(); + } + + return { fieldErrors, permissionLevel }; +} diff --git a/src/routes/locations/[location_id]/settings/+page.server.ts b/src/routes/locations/[location_id]/settings/+page.server.ts index 9941a6c3..def6d264 100644 --- a/src/routes/locations/[location_id]/settings/+page.server.ts +++ b/src/routes/locations/[location_id]/settings/+page.server.ts @@ -1,73 +1,15 @@ -import { - ApiService, - ApiServiceError, - type CreateLocationOwnerRequest, - type LocationOwnerDto, - type UpdateLocationOwnerRequest -} from '$lib/api/api.service'; +import { ApiService, type LocationOwnerDto } from '$lib/api/api.service'; import { m } from '$lib/paraglide/messages.js'; -import { fail, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; - -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -type AddPermissionFormValues = { - newUserEmail: string; - userId?: string; - permission_level: number; - applyToAllDevices: boolean; -}; - -type EditPermissionFormValues = { - ownerId: string; - permissionId: string; - userId?: string; - applyToAllDevices: boolean; -}; - -const EMPTY_ADD_VALUES: AddPermissionFormValues = { - newUserEmail: '', - userId: '', - permission_level: 4, - applyToAllDevices: false -}; - -const EMPTY_EDIT_VALUES: EditPermissionFormValues = { - ownerId: '', - permissionId: '', - userId: '', - applyToAllDevices: false -}; - -const readString = (value: FormDataEntryValue | null): string => { - if (typeof value !== 'string') return ''; - return value.trim(); -}; - -const readUnknownString = (value: unknown): string => { - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - return ''; -}; - -const readErrorMessage = (error: unknown, fallback: string): string => { - if (error instanceof ApiServiceError) { - const payload = error.payload; - if (payload && typeof payload === 'object') { - const record = payload as Record; - const message = record.message; - if (Array.isArray(message)) { - const combined = message - .filter((entry): entry is string => typeof entry === 'string') - .join(', '); - if (combined.length > 0) return combined; - } - if (typeof message === 'string' && message.trim().length > 0) return message.trim(); - } - } - - return fallback; -}; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { + addPermission, + editPermission, + readUnknownString, + removePermission, + updateLocationName, + updateUserPermissionLevel +} from './location-settings-actions.server'; export const load: PageServerLoad = async ({ locals, fetch, params }) => { const authToken = locals.jwtString ?? null; @@ -113,321 +55,9 @@ export const load: PageServerLoad = async ({ locals, fetch, params }) => { }; export const actions: Actions = { - updateLocationName: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - if (!authToken) { - return fail(401, { - action: 'updateLocationName', - message: m.locations_update_name_requires_login() - }); - } - - const locationId = Number.parseInt(params.location_id ?? '', 10); - if (!Number.isFinite(locationId)) { - return fail(400, { - action: 'updateLocationName', - message: m.locations_invalid_location_id() - }); - } - - const formData = await request.formData(); - const newName = readString(formData.get('locationName')); - const newGroup = readString(formData.get('group')); - - if (!newName) { - return fail(400, { - action: 'updateLocationName', - message: m.locations_name_cannot_be_empty() - }); - } - - const apiService = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - await apiService.updateLocation(locationId, { name: newName, group: newGroup }); - - return { - action: 'updateLocationName', - success: true, - message: m.locations_name_updated() - }; - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'updateLocationName', - message: readErrorMessage(error, m.locations_update_name_failed()) - }); - } - }, - addPermission: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - if (!authToken) { - return fail(401, { - action: 'addPermission', - message: m.locations_manage_permissions_requires_login(), - values: EMPTY_ADD_VALUES - }); - } - - const locationId = Number.parseInt(params.location_id ?? '', 10); - if (!Number.isFinite(locationId)) { - return fail(400, { - action: 'addPermission', - message: m.locations_invalid_location_id(), - values: EMPTY_ADD_VALUES - }); - } - - const formData = await request.formData(); - const values: AddPermissionFormValues = { - newUserEmail: readString(formData.get('newUserEmail')), - permission_level: Number.parseInt(readString(formData.get('permission_level')), 10) || 4, - applyToAllDevices: formData.get('applyToAllDevices') === 'true' - }; - - const fieldErrors: Partial> = {}; - if (!values.newUserEmail) { - fieldErrors.newUserEmail = m.validation_user_email_required(); - } else if (!EMAIL_PATTERN.test(values.newUserEmail)) { - fieldErrors.newUserEmail = m.validation_valid_email_required(); - } - - if (Object.keys(fieldErrors).length > 0) { - return fail(400, { - action: 'addPermission', - message: m.validation_correct_highlighted_fields(), - values, - fieldErrors - }); - } - - const apiService = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - await apiService.createLocationPermission( - locationId, - values.newUserEmail, - values.permission_level ?? 4, - values.applyToAllDevices - ); - - return { - action: 'addPermission', - success: true, - message: m.locations_permission_created(), - values: EMPTY_ADD_VALUES - }; - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'addPermission', - message: readErrorMessage(error, m.locations_permission_create_failed()), - values, - fieldErrors - }); - } - }, - editPermission: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - if (!authToken) { - return fail(401, { - action: 'editPermission', - message: m.locations_manage_permissions_requires_login(), - values: EMPTY_EDIT_VALUES - }); - } - - const locationId = Number.parseInt(params.location_id ?? '', 10); - if (!Number.isFinite(locationId)) { - return fail(400, { - action: 'editPermission', - message: m.locations_invalid_location_id(), - values: EMPTY_EDIT_VALUES - }); - } - - const formData = await request.formData(); - const values: EditPermissionFormValues = { - ownerId: readString(formData.get('ownerId')), - permissionId: readString(formData.get('permissionId')), - userId: readString(formData.get('userId')), - applyToAllDevices: formData.get('applyToAllDevices') === 'true' - }; - - const fieldErrors: Partial> = {}; - const ownerId = Number.parseInt(values.ownerId, 10); - const permissionId = Number.parseInt(values.permissionId, 10); - - if (!Number.isFinite(ownerId)) { - fieldErrors.ownerId = m.locations_select_permission_to_edit(); - } - - if (!values.userId) { - fieldErrors.userId = m.locations_user_id_required(); - } - - const adminUserId = typeof locals.jwt?.sub === 'string' ? locals.jwt.sub.trim() : ''; - if (!adminUserId) { - return fail(400, { - action: 'editPermission', - message: m.locations_missing_admin_user_id(), - values, - fieldErrors - }); - } - - if (Object.keys(fieldErrors).length > 0) { - return fail(400, { - action: 'editPermission', - message: m.validation_correct_highlighted_fields(), - values, - fieldErrors - }); - } - - const updateLocationOwnerPayload: UpdateLocationOwnerRequest = { - admin_user_id: adminUserId, - location_id: locationId, - user_id: values.userId, - owner_id: ownerId - }; - if (Number.isFinite(permissionId)) { - updateLocationOwnerPayload.id = permissionId; - } - - const apiService = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - await apiService.updateLocationPermission( - locationId, - updateLocationOwnerPayload, - values.applyToAllDevices - ); - - return { - action: 'editPermission', - success: true, - message: m.locations_permission_updated(), - values: EMPTY_EDIT_VALUES - }; - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'editPermission', - message: readErrorMessage(error, m.locations_permission_update_failed()), - values, - fieldErrors - }); - } - }, - updateUserPermissionLevel: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - if (!authToken) { - return fail(401, { - action: 'updateUserPermissionLevel', - message: m.locations_manage_permissions_requires_login() - }); - } - - const locationId = Number.parseInt(params.location_id ?? '', 10); - if (!Number.isFinite(locationId)) { - return fail(400, { - action: 'updateUserPermissionLevel', - message: m.locations_invalid_location_id() - }); - } - - const formData = await request.formData(); - const permissionLevel = readString(formData.get('permission_level')); - const email = readString(formData.get('email')); - - if (!email) { - return fail(400, { - action: 'updateUserPermissionLevel', - message: m.validation_email_required() - }); - } - - if (!permissionLevel) { - return fail(400, { - action: 'updateUserPermissionLevel', - message: m.locations_permission_level_required() - }); - } - - const apiService = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - await apiService.updateLocationPermissionLevel(locationId, email, +permissionLevel); - - return { - action: 'updateUserPermissionLevel', - success: true, - message: m.locations_user_permission_level_updated() - }; - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'updateUserPermissionLevel', - message: readErrorMessage(error, m.locations_user_permission_level_update_failed()) - }); - } - }, - removePermission: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - if (!authToken) { - return fail(401, { - action: 'removePermission', - message: m.locations_manage_permissions_requires_login(), - values: EMPTY_ADD_VALUES - }); - } - - const locationId = readUnknownString(params.location_id); - if (!locationId) { - return fail(400, { - action: 'removePermission', - message: m.locations_invalid_location_id() - }); - } - - const formData = await request.formData(); - const permissionIdRaw = readString(formData.get('permission_id')); - const permissionId = Number.parseInt(permissionIdRaw, 10); - if (!Number.isFinite(permissionId)) { - return fail(400, { - action: 'removePermission', - message: m.locations_permission_id_required() - }); - } - - const apiService = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - const response = await apiService.deleteLocationPermission(locationId, permissionId); - console.log('Delete permission response:', response); - - return { - action: 'removePermission', - success: true, - message: m.locations_permission_removed() - }; - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'removePermission', - message: readErrorMessage(error, m.locations_permission_remove_failed()) - }); - } - } + updateLocationName, + addPermission, + editPermission, + updateUserPermissionLevel, + removePermission }; diff --git a/src/routes/locations/[location_id]/settings/location-settings-actions.server.ts b/src/routes/locations/[location_id]/settings/location-settings-actions.server.ts new file mode 100644 index 00000000..0efc7e01 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/location-settings-actions.server.ts @@ -0,0 +1,415 @@ +import { ApiService, ApiServiceError, type UpdateLocationOwnerRequest } from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { fail } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type AddPermissionFormValues = { + newUserEmail: string; + userId?: string; + permission_level: number; + applyToAllDevices: boolean; +}; + +type EditPermissionFormValues = { + ownerId: string; + permissionId: string; + userId?: string; + applyToAllDevices: boolean; +}; + +const EMPTY_ADD_VALUES: AddPermissionFormValues = { + newUserEmail: '', + userId: '', + permission_level: 4, + applyToAllDevices: false +}; + +const EMPTY_EDIT_VALUES: EditPermissionFormValues = { + ownerId: '', + permissionId: '', + userId: '', + applyToAllDevices: false +}; + +export const readString = (value: FormDataEntryValue | null): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +export const readUnknownString = (value: unknown): string => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return ''; +}; + +export const readErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof ApiServiceError) { + const payload = error.payload; + if (payload && typeof payload === 'object') { + const record = payload as Record; + const message = record.message; + if (Array.isArray(message)) { + const combined = message + .filter((entry): entry is string => typeof entry === 'string') + .join(', '); + if (combined.length > 0) return combined; + } + if (typeof message === 'string' && message.trim().length > 0) return message.trim(); + } + } + + return fallback; +}; + +function readLocationId(value: unknown): number | null { + const locationId = Number.parseInt(readUnknownString(value), 10); + return Number.isFinite(locationId) ? locationId : null; +} + +export const updateLocationName: Actions['updateLocationName'] = async ({ + request, + locals, + fetch, + params +}) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'updateLocationName', + message: m.locations_update_name_requires_login() + }); + } + + const locationId = readLocationId(params.location_id); + if (locationId === null) { + return fail(400, { + action: 'updateLocationName', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const newName = readString(formData.get('locationName')); + const newGroup = readString(formData.get('group')); + + if (!newName) { + return fail(400, { + action: 'updateLocationName', + message: m.locations_name_cannot_be_empty() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocation(locationId, { name: newName, group: newGroup }); + + return { + action: 'updateLocationName', + success: true, + message: m.locations_name_updated() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateLocationName', + message: readErrorMessage(error, m.locations_update_name_failed()) + }); + } +}; + +export const addPermission: Actions['addPermission'] = async ({ + request, + locals, + fetch, + params +}) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'addPermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_ADD_VALUES + }); + } + + const locationId = readLocationId(params.location_id); + if (locationId === null) { + return fail(400, { + action: 'addPermission', + message: m.locations_invalid_location_id(), + values: EMPTY_ADD_VALUES + }); + } + + const formData = await request.formData(); + const values: AddPermissionFormValues = { + newUserEmail: readString(formData.get('newUserEmail')), + permission_level: Number.parseInt(readString(formData.get('permission_level')), 10) || 4, + applyToAllDevices: formData.get('applyToAllDevices') === 'true' + }; + + const fieldErrors: Partial> = {}; + if (!values.newUserEmail) { + fieldErrors.newUserEmail = m.validation_user_email_required(); + } else if (!EMAIL_PATTERN.test(values.newUserEmail)) { + fieldErrors.newUserEmail = m.validation_valid_email_required(); + } + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'addPermission', + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.createLocationPermission( + locationId, + values.newUserEmail, + values.permission_level ?? 4, + values.applyToAllDevices + ); + + return { + action: 'addPermission', + success: true, + message: m.locations_permission_created(), + values: EMPTY_ADD_VALUES + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'addPermission', + message: readErrorMessage(error, m.locations_permission_create_failed()), + values, + fieldErrors + }); + } +}; + +export const editPermission: Actions['editPermission'] = async ({ + request, + locals, + fetch, + params +}) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'editPermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_EDIT_VALUES + }); + } + + const locationId = readLocationId(params.location_id); + if (locationId === null) { + return fail(400, { + action: 'editPermission', + message: m.locations_invalid_location_id(), + values: EMPTY_EDIT_VALUES + }); + } + + const formData = await request.formData(); + const values: EditPermissionFormValues = { + ownerId: readString(formData.get('ownerId')), + permissionId: readString(formData.get('permissionId')), + userId: readString(formData.get('userId')), + applyToAllDevices: formData.get('applyToAllDevices') === 'true' + }; + + const fieldErrors: Partial> = {}; + const ownerId = Number.parseInt(values.ownerId, 10); + const permissionId = Number.parseInt(values.permissionId, 10); + + if (!Number.isFinite(ownerId)) { + fieldErrors.ownerId = m.locations_select_permission_to_edit(); + } + + if (!values.userId) { + fieldErrors.userId = m.locations_user_id_required(); + } + + const adminUserId = typeof locals.jwt?.sub === 'string' ? locals.jwt.sub.trim() : ''; + if (!adminUserId) { + return fail(400, { + action: 'editPermission', + message: m.locations_missing_admin_user_id(), + values, + fieldErrors + }); + } + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'editPermission', + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const updateLocationOwnerPayload: UpdateLocationOwnerRequest = { + admin_user_id: adminUserId, + location_id: locationId, + user_id: values.userId, + owner_id: ownerId + }; + if (Number.isFinite(permissionId)) { + updateLocationOwnerPayload.id = permissionId; + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocationPermission( + locationId, + updateLocationOwnerPayload, + values.applyToAllDevices + ); + + return { + action: 'editPermission', + success: true, + message: m.locations_permission_updated(), + values: EMPTY_EDIT_VALUES + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'editPermission', + message: readErrorMessage(error, m.locations_permission_update_failed()), + values, + fieldErrors + }); + } +}; + +export const updateUserPermissionLevel: Actions['updateUserPermissionLevel'] = async ({ + request, + locals, + fetch, + params +}) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'updateUserPermissionLevel', + message: m.locations_manage_permissions_requires_login() + }); + } + + const locationId = readLocationId(params.location_id); + if (locationId === null) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const permissionLevel = readString(formData.get('permission_level')); + const email = readString(formData.get('email')); + + if (!email) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.validation_email_required() + }); + } + + if (!permissionLevel) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.locations_permission_level_required() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocationPermissionLevel(locationId, email, +permissionLevel); + + return { + action: 'updateUserPermissionLevel', + success: true, + message: m.locations_user_permission_level_updated() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateUserPermissionLevel', + message: readErrorMessage(error, m.locations_user_permission_level_update_failed()) + }); + } +}; + +export const removePermission: Actions['removePermission'] = async ({ + request, + locals, + fetch, + params +}) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'removePermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_ADD_VALUES + }); + } + + const locationId = readUnknownString(params.location_id); + if (!locationId) { + return fail(400, { + action: 'removePermission', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const permissionIdRaw = readString(formData.get('permission_id')); + const permissionId = Number.parseInt(permissionIdRaw, 10); + if (!Number.isFinite(permissionId)) { + return fail(400, { + action: 'removePermission', + message: m.locations_permission_id_required() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.deleteLocationPermission(locationId, permissionId); + + return { + action: 'removePermission', + success: true, + message: m.locations_permission_removed() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'removePermission', + message: readErrorMessage(error, m.locations_permission_remove_failed()) + }); + } +}; diff --git a/src/routes/reports/[report_id]/edit/+page.server.ts b/src/routes/reports/[report_id]/edit/+page.server.ts index c04d0707..d6a4a93e 100644 --- a/src/routes/reports/[report_id]/edit/+page.server.ts +++ b/src/routes/reports/[report_id]/edit/+page.server.ts @@ -1,427 +1,19 @@ -import { ApiService, ApiServiceError } from '$lib/api/api.service'; -import type { - CreateReportAlertPointRequest, - CreateReportDataProcessingScheduleRequest, - CreateReportRecipientRequest, - CreateReportRequest, - CreateReportUserScheduleRequest -} from '$lib/api/api.dtos'; +import { ApiService } from '$lib/api/api.service'; import { m } from '$lib/paraglide/messages.js'; import { error, fail, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; - -type CurrentUser = { - id: string; - email: string; - name: string; -}; - -const REPORT_CREATED_SUCCESS_MESSAGE = 'Report created successfully.'; -const REPORT_UPDATED_SUCCESS_MESSAGE = 'Report updated successfully.'; -const REPORT_UPDATE_FAILED_MESSAGE = 'Failed to update report.'; - -const readString = (value: unknown): string => - typeof value === 'string' - ? value.trim() - : typeof value === 'number' && Number.isFinite(value) - ? String(value) - : ''; - -const readOptionalString = (value: unknown): string | undefined => { - const normalized = readString(value); - return normalized.length > 0 ? normalized : undefined; -}; - -const readOptionalInteger = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isInteger(value)) { - return value; - } - - const normalized = readString(value); - if (!normalized) return undefined; - - const parsed = Number.parseInt(normalized, 10); - return Number.isFinite(parsed) ? parsed : undefined; -}; - -const readOptionalPositiveInteger = (value: unknown): number | undefined => { - const parsed = readOptionalInteger(value); - return parsed !== undefined && parsed > 0 ? parsed : undefined; -}; - -const readOptionalNumber = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - - const normalized = readString(value); - if (!normalized) return undefined; - - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : undefined; -}; - -const readOptionalBoolean = (value: unknown, fallback?: boolean): boolean | undefined => { - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - } - - return fallback; -}; - -const normalizeDevEui = (value: string): string => - value - .replace(/[^0-9a-fA-F]/g, '') - .toUpperCase() - .slice(0, 16); - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -function readApiMessage(sourceError: unknown, fallback: string): string { - if (sourceError instanceof ApiServiceError) { - const payload = sourceError.payload as { payload?: { message?: unknown } } | null; - const message = payload?.payload?.message; - - if (typeof message === 'string' && message.trim()) { - return message.trim(); - } - - if (Array.isArray(message)) { - const text = message - .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - .join(', '); - - if (text) return text; - } - } - - if (sourceError instanceof Error && sourceError.message.trim()) { - return sourceError.message.trim(); - } - - return fallback; -} - -function readReportId(payload: unknown): string | null { - if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { - return null; - } - - const record = payload as Record; - const reportId = record.report_id; - if (typeof reportId === 'string' && reportId.trim()) { - return reportId.trim(); - } - - for (const nestedKey of ['data', 'result']) { - const nestedReportId = readReportId(record[nestedKey]); - if (nestedReportId) { - return nestedReportId; - } - } - - return null; -} - -function readApiStatus(sourceError: unknown, fallback = 500): number { - if ( - sourceError instanceof ApiServiceError && - Number.isInteger(sourceError.status) && - sourceError.status >= 400 && - sourceError.status < 600 - ) { - return sourceError.status; - } - - return fallback; -} - -function parsePayload(formData: FormData): { raw: string; value: Record } | null { - const raw = readString(formData.get('payload')); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed)) { - return null; - } - - return { raw, value: parsed }; - } catch { - return null; - } -} - -function sanitizeScheduleEntries( - value: unknown, - defaults: { - dev_eui: string; - report_id?: string; - user_id?: string; - } -): CreateReportUserScheduleRequest[] | undefined { - if (!Array.isArray(value)) return undefined; - - const schedules = value - .filter(isRecord) - .map((entry) => { - const devEuiSource = readOptionalString(entry.dev_eui); - const devEui = normalizeDevEui(devEuiSource ?? defaults.dev_eui); - if (!devEui) { - return null; - } - - const schedule: CreateReportUserScheduleRequest = { - dev_eui: devEui, - end_of_day: readOptionalBoolean(entry.end_of_day, false) ?? false, - end_of_week: readOptionalBoolean(entry.end_of_week, false) ?? false, - is_active: readOptionalBoolean(entry.is_active, true) ?? true - }; - - const createdAt = readOptionalString(entry.created_at); - const id = readOptionalInteger(entry.id); - const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; - const reportUserScheduleId = readOptionalInteger(entry.report_user_schedule_id); - const userId = readOptionalString(entry.user_id) ?? defaults.user_id; - - if (createdAt) schedule.created_at = createdAt; - if (id !== undefined) schedule.id = id; - if (reportId) schedule.report_id = reportId; - if (reportUserScheduleId !== undefined) { - schedule.report_user_schedule_id = reportUserScheduleId; - } - if (userId) schedule.user_id = userId; - - return schedule; - }) - .filter((entry): entry is CreateReportUserScheduleRequest => entry !== null); - - return schedules.length > 0 ? schedules : undefined; -} - -function sanitizeAlertEntries( - value: unknown, - defaults: { - report_id?: string; - user_id?: string; - } -): CreateReportAlertPointRequest[] | undefined { - if (!Array.isArray(value)) return undefined; - - const alerts = value - .filter(isRecord) - .map((entry) => { - const name = readOptionalString(entry.name); - const dataPointKey = readOptionalString(entry.data_point_key); - - if (!name || !dataPointKey) { - return null; - } - - const alert: CreateReportAlertPointRequest = { - name, - data_point_key: dataPointKey - }; - - const createdAt = readOptionalString(entry.created_at); - const hexColor = readOptionalString(entry.hex_color); - const id = readOptionalInteger(entry.id); - const max = readOptionalNumber(entry.max); - const min = readOptionalNumber(entry.min); - const operator = readOptionalString(entry.operator); - const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; - const userId = readOptionalString(entry.user_id) ?? defaults.user_id; - const valueNumber = readOptionalNumber(entry.value); - - if (createdAt) alert.created_at = createdAt; - if (hexColor) alert.hex_color = hexColor; - if (id !== undefined) alert.id = id; - if (max !== undefined) alert.max = max; - if (min !== undefined) alert.min = min; - if (operator) alert.operator = operator; - if (reportId) alert.report_id = reportId; - if (userId) alert.user_id = userId; - if (valueNumber !== undefined) alert.value = valueNumber; - - return alert; - }) - .filter((entry): entry is CreateReportAlertPointRequest => entry !== null); - - return alerts.length > 0 ? alerts : undefined; -} - -function sanitizeRecipientEntries( - value: unknown, - defaults: { - report_id?: string; - user_id?: string; - } -): CreateReportRecipientRequest[] | undefined { - if (!Array.isArray(value)) return undefined; - - const recipients = value - .filter(isRecord) - .map((entry) => { - const communicationMethod = readOptionalInteger(entry.communication_method); - const email = readOptionalString(entry.email); - const name = readOptionalString(entry.name); - const userId = readOptionalString(entry.user_id) ?? defaults.user_id; - - if (!communicationMethod || (!email && !userId)) { - return null; - } - - const recipient: CreateReportRecipientRequest = { - communication_method: communicationMethod - }; - - const createdAt = readOptionalString(entry.created_at); - const id = readOptionalInteger(entry.id); - const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; - - if (createdAt) recipient.created_at = createdAt; - if (email) recipient.email = email; - if (id !== undefined) recipient.id = id; - if (name) recipient.name = name; - if (reportId) recipient.report_id = reportId; - if (userId) recipient.user_id = userId; - - return recipient; - }) - .filter((entry): entry is CreateReportRecipientRequest => entry !== null); - - return recipients.length > 0 ? recipients : undefined; -} - -function sanitizeDataProcessingScheduleEntries( - value: unknown, - defaults: { report_id?: string } -): CreateReportDataProcessingScheduleRequest[] | undefined { - if (!Array.isArray(value)) return undefined; - - // Accept both HH:mm and HH:mm:ss; normalize to HH:mm. - const TIME_PATTERN = /^(\d{2}:\d{2})(?::\d{2})?$/; - const normalizeTime = (t: unknown): string | undefined => { - if (typeof t !== 'string') return undefined; - const match = TIME_PATTERN.exec(t.trim()); - return match ? match[1] : undefined; - }; - - const entries = value - .filter(isRecord) - .map((entry) => { - const dayOfWeek = readOptionalInteger(entry.day_of_week); - const startTime = normalizeTime(entry.start_time); - const endTime = normalizeTime(entry.end_time); - - if (dayOfWeek === undefined || !startTime || !endTime) { - return null; - } - - const schedule: CreateReportDataProcessingScheduleRequest = { - day_of_week: dayOfWeek, - start_time: startTime, - end_time: endTime - }; - - const crossesMidnight = readOptionalBoolean(entry.crosses_midnight); - const id = readOptionalString(entry.id); - const isEnabled = readOptionalBoolean(entry.is_enabled); - const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; - const ruleType = readOptionalString(entry.rule_type); - const timezone = readOptionalString(entry.timezone); - - if (crossesMidnight !== undefined) schedule.crosses_midnight = crossesMidnight; - if (id) schedule.id = id; - if (isEnabled !== undefined) schedule.is_enabled = isEnabled; - if (reportId) schedule.report_id = reportId; - if (ruleType) schedule.rule_type = ruleType; - if (timezone) schedule.timezone = timezone; - - return schedule; - }) - .filter((e): e is CreateReportDataProcessingScheduleRequest => e !== null); - - return entries.length > 0 ? entries : undefined; -} - -function sanitizeReportPayload( - payload: Record, - defaults: { - report_id?: string; - user_id?: string; - } -): CreateReportRequest { - const name = readString(payload.name); - const devEui = normalizeDevEui(readString(payload.dev_eui)); - const reportId = readOptionalString(payload.report_id) ?? defaults.report_id; - const userId = readOptionalString(payload.user_id) ?? defaults.user_id; - - const sanitized: CreateReportRequest = { - name, - dev_eui: devEui - }; - - const createdAt = readOptionalString(payload.created_at); - const dataPullInterval = readOptionalPositiveInteger(payload.data_pull_interval); - const id = readOptionalInteger(payload.id); - - if (createdAt) sanitized.created_at = createdAt; - if (dataPullInterval !== undefined) sanitized.data_pull_interval = dataPullInterval; - if (id !== undefined) sanitized.id = id; - if (reportId) sanitized.report_id = reportId; - if (userId) sanitized.user_id = userId; - - const reportUserSchedule = sanitizeScheduleEntries(payload.report_user_schedule, { - dev_eui: devEui, - report_id: reportId, - user_id: userId - }); - const reportAlertPoints = sanitizeAlertEntries(payload.report_alert_points, { - report_id: reportId, - user_id: userId - }); - const reportRecipients = sanitizeRecipientEntries(payload.report_recipients, { - report_id: reportId, - user_id: userId - }); - - const reportDataProcessingSchedules = sanitizeDataProcessingScheduleEntries( - payload.report_data_processing_schedules, - { report_id: reportId } - ); - - if (reportUserSchedule) sanitized.report_user_schedule = reportUserSchedule; - if (reportAlertPoints) sanitized.report_alert_points = reportAlertPoints; - if (reportRecipients) sanitized.report_recipients = reportRecipients; - if (reportDataProcessingSchedules) - sanitized.report_data_processing_schedules = reportDataProcessingSchedules; - - return sanitized; -} - -function readCurrentUser(locals: App.Locals): CurrentUser | null { - const jwt = locals.jwt; - const id = jwt?.sub?.trim(); - if (!jwt || !id) { - return null; - } - - const email = jwt.user_metadata?.email?.trim() || jwt.email?.trim() || ''; - const name = - jwt.user_metadata?.full_name?.trim() || jwt.user_metadata?.name?.trim() || email || id; - - return { id, email, name }; -} +import { + REPORT_CREATED_SUCCESS_MESSAGE, + REPORT_UPDATED_SUCCESS_MESSAGE, + REPORT_UPDATE_FAILED_MESSAGE, + parsePayload, + readApiMessage, + readApiStatus, + readCurrentUser, + readReportId, + readString, + sanitizeReportPayload +} from './report-action.server'; export const load: PageServerLoad = async ({ locals, fetch, params, url }) => { const authToken = locals.jwtString ?? null; diff --git a/src/routes/reports/[report_id]/edit/+page.svelte b/src/routes/reports/[report_id]/edit/+page.svelte index d7908ac4..681566c1 100644 --- a/src/routes/reports/[report_id]/edit/+page.svelte +++ b/src/routes/reports/[report_id]/edit/+page.svelte @@ -16,36 +16,27 @@ CwInput, useCwToast } from '@cropwatchdevelopment/cwui'; - import { type CwAlertPointCondition, type CwAlertPointsValue } from '@cropwatchdevelopment/cwui'; - import type { - CreateReportAlertPointRequest, - CreateReportDataProcessingScheduleRequest, - CreateReportRecipientRequest, - CreateReportRequest, - CreateReportUserScheduleRequest, - DeviceDto, - ReportDto - } from '$lib/api/api.dtos'; + import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui'; + import type { DeviceDto } from '$lib/api/api.dtos'; import type { PageProps } from './$types'; import ReportCadenceSection from './ReportCadenceSection.svelte'; import ReportProcessingSchedulesSection from './ReportProcessingSchedulesSection.svelte'; import ReportRecipientsSection from './ReportRecipientsSection.svelte'; import SAVE_ICON from '$lib/images/icons/save.svg'; import CANCEL_ICON from '$lib/images/icons/no.svg'; - import type { - DataProcessingScheduleDraft, - RecipientDraft, - ReportDraft, - ScheduleDraft, - SelectOption - } from './report-form.types'; + import type { ReportDraft, SelectOption } from './report-form.types'; import { createReportAlertPointsEditorText } from './alert-points-editor-text'; + import { buildReportDataPullIntervalOptions } from './data-pull-interval'; import { - DEFAULT_REPORT_DATA_PULL_INTERVAL, - buildReportDataPullIntervalOptions, - normalizeReportDataPullInterval, - parseReportDataPullInterval - } from './data-pull-interval'; + buildRequestPayload, + buildValidationIssues, + cleanOptional, + createEmptyAlertPointsValue, + createReportDraftFactory, + normalizeDevEui, + preventImplicitFormSubmission, + type CurrentUser + } from './report-form'; type ReportForm = { error?: string; @@ -55,19 +46,11 @@ success?: boolean; } | null; - type CurrentUser = { - email: string; - id: string; - name: string; - } | null; - type DeviceListEntry = { dev_eui: string; name?: string | null; }; - const DEFAULT_ALERT_COLOR = ''; - const DEFAULT_ALERT_DATA_POINT_KEY = 'temperature_c'; const REPORT_CREATED_SUCCESS_MESSAGE = 'Report created successfully.'; const REPORT_UPDATED_SUCCESS_MESSAGE = 'Report updated successfully.'; @@ -79,8 +62,9 @@ const isEditing = report !== null; const authToken = (() => data.authToken ?? null)(); const serverDevices = (() => data.devices ?? [])(); + const currentUser = (() => (data.currentUser ?? null) as CurrentUser)(); + const initialDevEui = (() => data.devEui ?? '')(); let actionForm = $derived((form ?? null) as ReportForm); - let currentUser = $derived((data.currentUser ?? null) as CurrentUser); let communicationMethodOptions = $derived([ { label: m.reports_create_method_email(), value: '1' }, { label: m.reports_create_method_sms(), value: '2' }, @@ -109,492 +93,15 @@ return `${prefix}-${rowSequence}`; } - function normalizeDevEui(value: string): string { - return value - .replace(/[^0-9a-fA-F]/g, '') - .toUpperCase() - .slice(0, 16); - } - - function cleanOptional(value: string): string | undefined { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - - function parseOptionalInteger(value: string): number | undefined { - const normalized = cleanOptional(value); - if (!normalized) return undefined; - - const parsed = Number.parseInt(normalized, 10); - return Number.isFinite(parsed) ? parsed : undefined; - } - - function parseOptionalNumber(value: string): number | undefined { - const normalized = cleanOptional(value); - if (!normalized) return undefined; - - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : undefined; - } - - function preventImplicitFormSubmission(node: HTMLFormElement) { - function normalizeButtonTypes() { - for (const button of node.querySelectorAll('button:not([type])')) { - button.type = 'button'; - } - } - - function handleKeydown(event: KeyboardEvent) { - if (event.key !== 'Enter' || event.defaultPrevented) return; - - const target = event.target; - if (!(target instanceof HTMLElement)) return; - if (target instanceof HTMLButtonElement) return; - if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return; - if (target instanceof HTMLInputElement) { - if (['button', 'checkbox', 'file', 'radio', 'reset', 'submit'].includes(target.type)) { - return; - } - } - - event.preventDefault(); - } - - normalizeButtonTypes(); - - const observer = new MutationObserver(() => { - normalizeButtonTypes(); - }); - - observer.observe(node, { childList: true, subtree: true }); - node.addEventListener('keydown', handleKeydown); - - return () => { - observer.disconnect(); - node.removeEventListener('keydown', handleKeydown); - }; - } - - function createEmptyAlertPointsValue(): CwAlertPointsValue { - return { - unit: 'C', - center: '0', - points: [] - }; - } - - function mapAlertConditionToOperator(condition: CwAlertPointCondition): string { - switch (condition) { - case 'greaterThan': - return '>'; - case 'greaterThanOrEqual': - return '>='; - case 'lessThan': - return '<'; - case 'lessThanOrEqual': - return '<='; - case 'range': - return 'range'; - case 'equals': - default: - return '='; - } - } - - function mapOperatorToAlertCondition(operator: string | null | undefined): CwAlertPointCondition { - switch (operator) { - case '>': - return 'greaterThan'; - case '>=': - return 'greaterThanOrEqual'; - case '<': - return 'lessThan'; - case '<=': - return 'lessThanOrEqual'; - case 'range': - return 'range'; - case '=': - default: - return 'equals'; - } - } - - function buildReportAlertPoints( - alertPoints: CwAlertPointsValue, - defaults: { - reportId?: string; - userId?: string; - } - ): CreateReportAlertPointRequest[] { - return alertPoints.points.map((point) => { - const alertPayload: CreateReportAlertPointRequest = { - data_point_key: DEFAULT_ALERT_DATA_POINT_KEY, - name: point.name.trim() - }; - - if (point.id !== undefined && point.id !== null && point.id !== '') { - const parsedId = Number(point.id); - if (!isNaN(parsedId)) { - alertPayload.id = parsedId; - } - } - - const alertHexColor = cleanOptional(point.color)?.toUpperCase(); - const alertMax = parseOptionalNumber(point.max); - const alertMin = parseOptionalNumber(point.min); - const alertOperator = cleanOptional(mapAlertConditionToOperator(point.condition)); - const alertValue = parseOptionalNumber(point.value); - - if (alertHexColor) alertPayload.hex_color = alertHexColor; - if (alertMax !== undefined) alertPayload.max = alertMax; - if (alertMin !== undefined) alertPayload.min = alertMin; - if (alertOperator) alertPayload.operator = alertOperator; - if (defaults.reportId) alertPayload.report_id = defaults.reportId; - if (defaults.userId) alertPayload.user_id = defaults.userId; - if (alertValue !== undefined) alertPayload.value = alertValue; - - return alertPayload; - }); - } - - function buildAlertValidationIssues(alertPoints: CwAlertPointsValue): string[] { - const issues: string[] = []; - - alertPoints.points.forEach((point, index) => { - const rowIndex = String(index + 1); - - if (!point.name.trim()) { - issues.push(m.reports_create_validation_alert_name({ index: rowIndex })); - } - - if (point.condition === 'range') { - const min = parseOptionalNumber(point.min); - const max = parseOptionalNumber(point.max); - - if (min === undefined || max === undefined) { - issues.push(m.reports_create_validation_alert_range_required({ index: rowIndex })); - } else if (min > max) { - issues.push(m.reports_create_validation_alert_range_order({ index: rowIndex })); - } - } else if (parseOptionalNumber(point.value) === undefined) { - issues.push(m.reports_create_validation_alert_value({ index: rowIndex })); - } - - const hexColor = cleanOptional(point.color); - if (hexColor && !/^#[0-9a-fA-F]{6}$/.test(hexColor)) { - issues.push(m.reports_create_validation_alert_hex({ index: rowIndex })); - } - }); - - return issues; - } - - function createEmptySchedule(rootUserId = currentUser?.id ?? ''): ScheduleDraft { - return { - created_at: '', - dev_eui: '', - end_of_day: false, - end_of_week: true, - id: '', - is_active: true, - key: nextRowKey('schedule'), - report_id: report?.report_id ?? '', - report_user_schedule_id: '', - user_id: rootUserId - }; - } - - function createEmptyDataProcessingSchedule(): DataProcessingScheduleDraft { - return { - crosses_midnight: false, - day_of_week: '1', - end_time: '17:00', - id: '', - is_enabled: true, - key: nextRowKey('dps'), - report_id: report?.report_id ?? '', - rule_type: 'include', - start_time: '09:00', - timezone: 'JST' - }; - } - - function createEmptyRecipient(rootUserId = currentUser?.id ?? ''): RecipientDraft { - return { - communication_method: '1', - created_at: '', - email: currentUser?.email ?? '', - id: '', - key: nextRowKey('recipient'), - name: currentUser?.name ?? '', - report_id: report?.report_id ?? '', - user_id: rootUserId - }; - } - - function buildDefaultDraft(): ReportDraft { - return { - created_at: '', - data_pull_interval: DEFAULT_REPORT_DATA_PULL_INTERVAL, - dev_eui: normalizeDevEui(data.devEui ?? ''), - id: '', - name: '', - report_id: '', - report_data_processing_schedules: [], - report_recipients: [createEmptyRecipient()], - report_user_schedule: [createEmptySchedule()], - user_id: currentUser?.id ?? '' - }; - } - - function buildDraftFromReport(source: ReportDto): ReportDraft { - const normalizedReportDevEui = normalizeDevEui(source.dev_eui ?? ''); - const rootUserId = cleanOptional(source.user_id ?? '') ?? currentUser?.id ?? ''; - const reportId = source.report_id ?? ''; - - const schedules = - source.report_user_schedule?.map((schedule) => { - const scheduleDevEui = normalizeDevEui(schedule.dev_eui ?? ''); - - return { - created_at: schedule.created_at ?? '', - dev_eui: scheduleDevEui === normalizedReportDevEui ? '' : scheduleDevEui, - end_of_day: schedule.end_of_day ?? false, - end_of_week: schedule.end_of_week ?? false, - id: schedule.id != null ? String(schedule.id) : '', - is_active: schedule.is_active ?? true, - key: nextRowKey('schedule'), - report_id: cleanOptional(schedule.report_id ?? '') ?? reportId, - report_user_schedule_id: - schedule.report_user_schedule_id != null - ? String(schedule.report_user_schedule_id) - : '', - user_id: cleanOptional(schedule.user_id ?? '') ?? rootUserId - }; - }) ?? []; - - const recipients = - source.report_recipients?.map((recipient) => ({ - communication_method: - recipient.communication_method != null ? String(recipient.communication_method) : '1', - created_at: recipient.created_at ?? '', - email: recipient.email ?? '', - id: recipient.id != null ? String(recipient.id) : '', - key: nextRowKey('recipient'), - name: recipient.name ?? '', - report_id: cleanOptional(recipient.report_id ?? '') ?? reportId, - user_id: cleanOptional(recipient.user_id ?? '') ?? rootUserId - })) ?? []; - - const dataProcessingSchedules = - source.report_data_processing_schedules?.map((s) => ({ - crosses_midnight: s.crosses_midnight ?? false, - day_of_week: s.day_of_week != null ? String(s.day_of_week) : '1', - end_time: s.end_time ?? '17:00', - id: s.id ?? '', - is_enabled: s.is_enabled ?? true, - key: nextRowKey('dps'), - report_id: s.report_id ?? reportId, - rule_type: s.rule_type ?? 'include', - start_time: s.start_time ?? '09:00', - timezone: s.timezone ?? 'UTC' - })) ?? []; - - return { - created_at: source.created_at ?? '', - data_pull_interval: normalizeReportDataPullInterval(source.data_pull_interval), - dev_eui: normalizedReportDevEui, - id: source.id != null ? String(source.id) : '', - name: source.name ?? '', - report_id: reportId, - report_data_processing_schedules: dataProcessingSchedules, - report_recipients: recipients.length > 0 ? recipients : [createEmptyRecipient(rootUserId)], - report_user_schedule: schedules.length > 0 ? schedules : [createEmptySchedule(rootUserId)], - user_id: rootUserId - }; - } - - function buildAlertPointsValueFromReport(source: ReportDto): CwAlertPointsValue { - const baseValue = createEmptyAlertPointsValue(); - const points = - source.report_alert_points?.map((point, index) => { - const condition = - typeof point.operator === 'string' && point.operator.trim().length > 0 - ? mapOperatorToAlertCondition(point.operator) - : point.min != null || point.max != null - ? 'range' - : 'equals'; - const fallbackId = point.id != null ? String(point.id) : nextRowKey(`alert-${index + 1}`); - - return { - color: cleanOptional(point.hex_color ?? '')?.toUpperCase() ?? DEFAULT_ALERT_COLOR, - condition, - id: fallbackId, - max: point.max != null ? String(point.max) : '', - min: point.min != null ? String(point.min) : '', - name: point.name ?? '', - value: point.value != null ? String(point.value) : '' - }; - }) ?? []; - - return { ...baseValue, points }; - } - - function buildRequestPayload( - draft: ReportDraft, - rootDevEui: string, - alertPoints: CwAlertPointsValue - ): CreateReportRequest { - const createdAt = cleanOptional(draft.created_at); - const id = parseOptionalInteger(draft.id); - const reportId = cleanOptional(draft.report_id); - const userId = cleanOptional(draft.user_id) ?? currentUser?.id ?? undefined; - const devEui = normalizeDevEui(rootDevEui); - const dataPullInterval = parseReportDataPullInterval(draft.data_pull_interval); - - const reportUserSchedule: CreateReportUserScheduleRequest[] = draft.report_user_schedule.map( - (schedule) => { - const schedulePayload: CreateReportUserScheduleRequest = { - dev_eui: normalizeDevEui(schedule.dev_eui) || devEui, - end_of_day: schedule.end_of_day, - end_of_week: schedule.end_of_week, - is_active: schedule.is_active - }; - - const scheduleCreatedAt = cleanOptional(schedule.created_at); - const scheduleId = parseOptionalInteger(schedule.id); - const scheduleReportId = cleanOptional(schedule.report_id) ?? reportId; - const scheduleRowId = parseOptionalInteger(schedule.report_user_schedule_id); - const scheduleUserId = cleanOptional(schedule.user_id) ?? userId; - - if (scheduleCreatedAt) schedulePayload.created_at = scheduleCreatedAt; - if (scheduleId !== undefined) schedulePayload.id = scheduleId; - if (scheduleReportId) schedulePayload.report_id = scheduleReportId; - if (scheduleRowId !== undefined) schedulePayload.report_user_schedule_id = scheduleRowId; - if (scheduleUserId) schedulePayload.user_id = scheduleUserId; - - return schedulePayload; - } - ); - - const reportAlertPoints = buildReportAlertPoints(alertPoints, { reportId, userId }); - - const reportRecipients: CreateReportRecipientRequest[] = draft.report_recipients.map( - (recipient) => { - const recipientPayload: CreateReportRecipientRequest = { - communication_method: parseOptionalInteger(recipient.communication_method) ?? 1 - }; - - const recipientCreatedAt = cleanOptional(recipient.created_at); - const recipientEmail = cleanOptional(recipient.email); - const recipientId = parseOptionalInteger(recipient.id); - const recipientName = cleanOptional(recipient.name); - const recipientReportId = cleanOptional(recipient.report_id) ?? reportId; - const recipientUserId = cleanOptional(recipient.user_id) ?? userId; - - if (recipientCreatedAt) recipientPayload.created_at = recipientCreatedAt; - if (recipientEmail) recipientPayload.email = recipientEmail; - if (recipientId !== undefined) recipientPayload.id = recipientId; - if (recipientName) recipientPayload.name = recipientName; - if (recipientReportId) recipientPayload.report_id = recipientReportId; - if (recipientUserId) recipientPayload.user_id = recipientUserId; - - return recipientPayload; - } - ); - - const reportDataProcessingSchedules: CreateReportDataProcessingScheduleRequest[] = - draft.report_data_processing_schedules - .filter((s) => s.day_of_week && s.start_time && s.end_time) - .map((s) => { - const entry: CreateReportDataProcessingScheduleRequest = { - day_of_week: parseInt(s.day_of_week, 10), - start_time: s.start_time, - end_time: s.end_time - }; - if (s.crosses_midnight !== undefined) entry.crosses_midnight = s.crosses_midnight; - if (s.id) entry.id = s.id; - entry.is_enabled = s.is_enabled; - if (s.report_id) entry.report_id = s.report_id; - if (s.rule_type) entry.rule_type = s.rule_type; - if (s.timezone) entry.timezone = s.timezone; - return entry; - }); - - const payload: CreateReportRequest = { - dev_eui: devEui, - name: draft.name.trim(), - report_alert_points: reportAlertPoints, - report_data_processing_schedules: reportDataProcessingSchedules, - report_recipients: reportRecipients, - report_user_schedule: reportUserSchedule - }; - - if (createdAt) payload.created_at = createdAt; - if (dataPullInterval !== undefined) payload.data_pull_interval = dataPullInterval; - if (id !== undefined) payload.id = id; - if (reportId) payload.report_id = reportId; - if (userId) payload.user_id = userId; - - return payload; - } - - function buildValidationIssues( - draft: ReportDraft, - rootDevEui: string, - alertPoints: CwAlertPointsValue - ): string[] { - const issues: string[] = []; - const normalizedRootDevEui = normalizeDevEui(rootDevEui); - const rootUserId = cleanOptional(draft.user_id) ?? currentUser?.id ?? undefined; - - if (!draft.name.trim()) { - issues.push(m.reports_create_validation_report_name_required()); - } - - if (normalizedRootDevEui.length !== 16) { - issues.push(m.reports_create_validation_dev_eui_length()); - } - - draft.report_user_schedule.forEach((schedule, index) => { - const scheduleDevEui = normalizeDevEui(schedule.dev_eui) || normalizedRootDevEui; - const rowIndex = String(index + 1); - - if (scheduleDevEui.length !== 16) { - issues.push(m.reports_create_validation_schedule_dev_eui({ index: rowIndex })); - } - - if (schedule.is_active && !schedule.end_of_week && !schedule.end_of_day) { - issues.push(m.reports_create_validation_schedule_cadence({ index: rowIndex })); - } - }); - - issues.push(...buildAlertValidationIssues(alertPoints)); - - draft.report_recipients.forEach((recipient, index) => { - const rowIndex = String(index + 1); - const communicationMethod = parseOptionalInteger(recipient.communication_method); - const email = cleanOptional(recipient.email); - const userId = cleanOptional(recipient.user_id) ?? rootUserId; - - if (!communicationMethod) { - issues.push(m.reports_create_validation_recipient_method({ index: rowIndex })); - } - - if (!email && !userId) { - issues.push(m.reports_create_validation_recipient_destination({ index: rowIndex })); - } - - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - issues.push(m.reports_create_validation_recipient_email({ index: rowIndex })); - } - }); - - return issues; - } + const reportDrafts = createReportDraftFactory({ + currentUser, + initialDevEui, + nextRowKey, + report + }); function addDataProcessingSchedule() { - fields.report_data_processing_schedules.push(createEmptyDataProcessingSchedule()); + fields.report_data_processing_schedules.push(reportDrafts.createEmptyDataProcessingSchedule()); } function removeDataProcessingSchedule(key: string) { @@ -603,7 +110,10 @@ } function addRecipient() { - fields.report_recipients = [...fields.report_recipients, createEmptyRecipient(fields.user_id)]; + fields.report_recipients = [ + ...fields.report_recipients, + reportDrafts.createEmptyRecipient(fields.user_id) + ]; } function removeRecipient(key: string) { @@ -612,13 +122,14 @@ ); } - let fields = $state(report ? buildDraftFromReport(report) : buildDefaultDraft()); + let fields = $state( + report ? reportDrafts.buildDraftFromReport(report) : reportDrafts.buildDefaultDraft() + ); let alertPointsValue = $state( - report ? buildAlertPointsValueFromReport(report) : createEmptyAlertPointsValue() + report ? reportDrafts.buildAlertPointsValueFromReport(report) : createEmptyAlertPointsValue() ); let fallbackDevices = $state([]); let loadingDevices = $state(false); - const initialDevEui = (() => data.devEui ?? '')(); let selectedDevEui = $state( report ? normalizeDevEui(report.dev_eui ?? '') : normalizeDevEui(initialDevEui) ); @@ -698,11 +209,11 @@ ...buildReportDataPullIntervalOptions(fields.data_pull_interval) ]); let requestPayload = $derived.by(() => - buildRequestPayload(fields, normalizedSelectedDevEui, alertPointsValue) + buildRequestPayload(fields, normalizedSelectedDevEui, alertPointsValue, currentUser) ); let requestPayloadCompact = $derived.by(() => JSON.stringify(requestPayload)); let validationIssues = $derived.by(() => - buildValidationIssues(fields, normalizedSelectedDevEui, alertPointsValue) + buildValidationIssues(fields, normalizedSelectedDevEui, alertPointsValue, currentUser) ); let alertPointsEditorText = $derived.by(() => createReportAlertPointsEditorText()); let canSubmit = $derived(validationIssues.length === 0); diff --git a/src/routes/reports/[report_id]/edit/report-action.server.ts b/src/routes/reports/[report_id]/edit/report-action.server.ts new file mode 100644 index 00000000..c7d40628 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/report-action.server.ts @@ -0,0 +1,421 @@ +import { ApiServiceError } from '$lib/api/api.service'; +import type { + CreateReportAlertPointRequest, + CreateReportDataProcessingScheduleRequest, + CreateReportRecipientRequest, + CreateReportRequest, + CreateReportUserScheduleRequest +} from '$lib/api/api.dtos'; + +export type CurrentUser = { + id: string; + email: string; + name: string; +}; + +export const REPORT_CREATED_SUCCESS_MESSAGE = 'Report created successfully.'; +export const REPORT_UPDATED_SUCCESS_MESSAGE = 'Report updated successfully.'; +export const REPORT_UPDATE_FAILED_MESSAGE = 'Failed to update report.'; + +export const readString = (value: unknown): string => + typeof value === 'string' + ? value.trim() + : typeof value === 'number' && Number.isFinite(value) + ? String(value) + : ''; + +const readOptionalString = (value: unknown): string | undefined => { + const normalized = readString(value); + return normalized.length > 0 ? normalized : undefined; +}; + +const readOptionalInteger = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + + const normalized = readString(value); + if (!normalized) return undefined; + + const parsed = Number.parseInt(normalized, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalPositiveInteger = (value: unknown): number | undefined => { + const parsed = readOptionalInteger(value); + return parsed !== undefined && parsed > 0 ? parsed : undefined; +}; + +const readOptionalNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + const normalized = readString(value); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalBoolean = (value: unknown, fallback?: boolean): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + + return fallback; +}; + +const normalizeDevEui = (value: string): string => + value + .replace(/[^0-9a-fA-F]/g, '') + .toUpperCase() + .slice(0, 16); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export function readApiMessage(sourceError: unknown, fallback: string): string { + if (sourceError instanceof ApiServiceError) { + const payload = sourceError.payload as { payload?: { message?: unknown } } | null; + const message = payload?.payload?.message; + + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + + if (Array.isArray(message)) { + const text = message + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .join(', '); + + if (text) return text; + } + } + + if (sourceError instanceof Error && sourceError.message.trim()) { + return sourceError.message.trim(); + } + + return fallback; +} + +export function readReportId(payload: unknown): string | null { + if (!isRecord(payload)) { + return null; + } + + const reportId = payload.report_id; + if (typeof reportId === 'string' && reportId.trim()) { + return reportId.trim(); + } + + for (const nestedKey of ['data', 'result']) { + const nestedReportId = readReportId(payload[nestedKey]); + if (nestedReportId) { + return nestedReportId; + } + } + + return null; +} + +export function readApiStatus(sourceError: unknown, fallback = 500): number { + if ( + sourceError instanceof ApiServiceError && + Number.isInteger(sourceError.status) && + sourceError.status >= 400 && + sourceError.status < 600 + ) { + return sourceError.status; + } + + return fallback; +} + +export function parsePayload( + formData: FormData +): { raw: string; value: Record } | null { + const raw = readString(formData.get('payload')); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return null; + } + + return { raw, value: parsed }; + } catch { + return null; + } +} + +function sanitizeScheduleEntries( + value: unknown, + defaults: { + dev_eui: string; + report_id?: string; + user_id?: string; + } +): CreateReportUserScheduleRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const schedules = value + .filter(isRecord) + .map((entry) => { + const devEuiSource = readOptionalString(entry.dev_eui); + const devEui = normalizeDevEui(devEuiSource ?? defaults.dev_eui); + if (!devEui) { + return null; + } + + const schedule: CreateReportUserScheduleRequest = { + dev_eui: devEui, + end_of_day: readOptionalBoolean(entry.end_of_day, false) ?? false, + end_of_week: readOptionalBoolean(entry.end_of_week, false) ?? false, + is_active: readOptionalBoolean(entry.is_active, true) ?? true + }; + + const createdAt = readOptionalString(entry.created_at); + const id = readOptionalInteger(entry.id); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const reportUserScheduleId = readOptionalInteger(entry.report_user_schedule_id); + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + + if (createdAt) schedule.created_at = createdAt; + if (id !== undefined) schedule.id = id; + if (reportId) schedule.report_id = reportId; + if (reportUserScheduleId !== undefined) { + schedule.report_user_schedule_id = reportUserScheduleId; + } + if (userId) schedule.user_id = userId; + + return schedule; + }) + .filter((entry): entry is CreateReportUserScheduleRequest => entry !== null); + + return schedules.length > 0 ? schedules : undefined; +} + +function sanitizeAlertEntries( + value: unknown, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportAlertPointRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const alerts = value + .filter(isRecord) + .map((entry) => { + const name = readOptionalString(entry.name); + const dataPointKey = readOptionalString(entry.data_point_key); + + if (!name || !dataPointKey) { + return null; + } + + const alert: CreateReportAlertPointRequest = { + name, + data_point_key: dataPointKey + }; + + const createdAt = readOptionalString(entry.created_at); + const hexColor = readOptionalString(entry.hex_color); + const id = readOptionalInteger(entry.id); + const max = readOptionalNumber(entry.max); + const min = readOptionalNumber(entry.min); + const operator = readOptionalString(entry.operator); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + const valueNumber = readOptionalNumber(entry.value); + + if (createdAt) alert.created_at = createdAt; + if (hexColor) alert.hex_color = hexColor; + if (id !== undefined) alert.id = id; + if (max !== undefined) alert.max = max; + if (min !== undefined) alert.min = min; + if (operator) alert.operator = operator; + if (reportId) alert.report_id = reportId; + if (userId) alert.user_id = userId; + if (valueNumber !== undefined) alert.value = valueNumber; + + return alert; + }) + .filter((entry): entry is CreateReportAlertPointRequest => entry !== null); + + return alerts.length > 0 ? alerts : undefined; +} + +function sanitizeRecipientEntries( + value: unknown, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportRecipientRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const recipients = value + .filter(isRecord) + .map((entry) => { + const communicationMethod = readOptionalInteger(entry.communication_method); + const email = readOptionalString(entry.email); + const name = readOptionalString(entry.name); + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + + if (!communicationMethod || (!email && !userId)) { + return null; + } + + const recipient: CreateReportRecipientRequest = { + communication_method: communicationMethod + }; + + const createdAt = readOptionalString(entry.created_at); + const id = readOptionalInteger(entry.id); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + + if (createdAt) recipient.created_at = createdAt; + if (email) recipient.email = email; + if (id !== undefined) recipient.id = id; + if (name) recipient.name = name; + if (reportId) recipient.report_id = reportId; + if (userId) recipient.user_id = userId; + + return recipient; + }) + .filter((entry): entry is CreateReportRecipientRequest => entry !== null); + + return recipients.length > 0 ? recipients : undefined; +} + +function sanitizeDataProcessingScheduleEntries( + value: unknown, + defaults: { report_id?: string } +): CreateReportDataProcessingScheduleRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const timePattern = /^(\d{2}:\d{2})(?::\d{2})?$/; + const normalizeTime = (time: unknown): string | undefined => { + if (typeof time !== 'string') return undefined; + const match = timePattern.exec(time.trim()); + return match ? match[1] : undefined; + }; + + const entries = value + .filter(isRecord) + .map((entry) => { + const dayOfWeek = readOptionalInteger(entry.day_of_week); + const startTime = normalizeTime(entry.start_time); + const endTime = normalizeTime(entry.end_time); + + if (dayOfWeek === undefined || !startTime || !endTime) { + return null; + } + + const schedule: CreateReportDataProcessingScheduleRequest = { + day_of_week: dayOfWeek, + start_time: startTime, + end_time: endTime + }; + + const crossesMidnight = readOptionalBoolean(entry.crosses_midnight); + const id = readOptionalString(entry.id); + const isEnabled = readOptionalBoolean(entry.is_enabled); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const ruleType = readOptionalString(entry.rule_type); + const timezone = readOptionalString(entry.timezone); + + if (crossesMidnight !== undefined) schedule.crosses_midnight = crossesMidnight; + if (id) schedule.id = id; + if (isEnabled !== undefined) schedule.is_enabled = isEnabled; + if (reportId) schedule.report_id = reportId; + if (ruleType) schedule.rule_type = ruleType; + if (timezone) schedule.timezone = timezone; + + return schedule; + }) + .filter((entry): entry is CreateReportDataProcessingScheduleRequest => entry !== null); + + return entries.length > 0 ? entries : undefined; +} + +export function sanitizeReportPayload( + payload: Record, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportRequest { + const name = readString(payload.name); + const devEui = normalizeDevEui(readString(payload.dev_eui)); + const reportId = readOptionalString(payload.report_id) ?? defaults.report_id; + const userId = readOptionalString(payload.user_id) ?? defaults.user_id; + + const sanitized: CreateReportRequest = { + name, + dev_eui: devEui + }; + + const createdAt = readOptionalString(payload.created_at); + const dataPullInterval = readOptionalPositiveInteger(payload.data_pull_interval); + const id = readOptionalInteger(payload.id); + + if (createdAt) sanitized.created_at = createdAt; + if (dataPullInterval !== undefined) sanitized.data_pull_interval = dataPullInterval; + if (id !== undefined) sanitized.id = id; + if (reportId) sanitized.report_id = reportId; + if (userId) sanitized.user_id = userId; + + const reportUserSchedule = sanitizeScheduleEntries(payload.report_user_schedule, { + dev_eui: devEui, + report_id: reportId, + user_id: userId + }); + const reportAlertPoints = sanitizeAlertEntries(payload.report_alert_points, { + report_id: reportId, + user_id: userId + }); + const reportRecipients = sanitizeRecipientEntries(payload.report_recipients, { + report_id: reportId, + user_id: userId + }); + const reportDataProcessingSchedules = sanitizeDataProcessingScheduleEntries( + payload.report_data_processing_schedules, + { report_id: reportId } + ); + + if (reportUserSchedule) sanitized.report_user_schedule = reportUserSchedule; + if (reportAlertPoints) sanitized.report_alert_points = reportAlertPoints; + if (reportRecipients) sanitized.report_recipients = reportRecipients; + if (reportDataProcessingSchedules) { + sanitized.report_data_processing_schedules = reportDataProcessingSchedules; + } + + return sanitized; +} + +export function readCurrentUser(locals: App.Locals): CurrentUser | null { + const jwt = locals.jwt; + const id = jwt?.sub?.trim(); + if (!jwt || !id) { + return null; + } + + const email = jwt.user_metadata?.email?.trim() || jwt.email?.trim() || ''; + const name = + jwt.user_metadata?.full_name?.trim() || jwt.user_metadata?.name?.trim() || email || id; + + return { id, email, name }; +} diff --git a/src/routes/reports/[report_id]/edit/report-form.ts b/src/routes/reports/[report_id]/edit/report-form.ts new file mode 100644 index 00000000..db19be4b --- /dev/null +++ b/src/routes/reports/[report_id]/edit/report-form.ts @@ -0,0 +1,542 @@ +import type { + CreateReportAlertPointRequest, + CreateReportDataProcessingScheduleRequest, + CreateReportRecipientRequest, + CreateReportRequest, + CreateReportUserScheduleRequest, + ReportDto +} from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import type { CwAlertPointCondition, CwAlertPointsValue } from '@cropwatchdevelopment/cwui'; +import { + DEFAULT_REPORT_DATA_PULL_INTERVAL, + normalizeReportDataPullInterval, + parseReportDataPullInterval +} from './data-pull-interval'; +import type { + DataProcessingScheduleDraft, + RecipientDraft, + ReportDraft, + ScheduleDraft +} from './report-form.types'; + +export type CurrentUser = { + email: string; + id: string; + name: string; +} | null; + +type ReportDraftFactoryOptions = { + currentUser: CurrentUser; + initialDevEui?: string | null; + nextRowKey: (prefix: string) => string; + report: ReportDto | null; +}; + +const DEFAULT_ALERT_COLOR = ''; +const DEFAULT_ALERT_DATA_POINT_KEY = 'temperature_c'; + +export function normalizeDevEui(value: string): string { + return value + .replace(/[^0-9a-fA-F]/g, '') + .toUpperCase() + .slice(0, 16); +} + +export function cleanOptional(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function parseOptionalInteger(value: string): number | undefined { + const normalized = cleanOptional(value); + if (!normalized) return undefined; + + const parsed = Number.parseInt(normalized, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseOptionalNumber(value: string): number | undefined { + const normalized = cleanOptional(value); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function preventImplicitFormSubmission(node: HTMLFormElement) { + function normalizeButtonTypes() { + for (const button of node.querySelectorAll('button:not([type])')) { + button.type = 'button'; + } + } + + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter' || event.defaultPrevented) return; + + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (target instanceof HTMLButtonElement) return; + if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return; + if (target instanceof HTMLInputElement) { + if (['button', 'checkbox', 'file', 'radio', 'reset', 'submit'].includes(target.type)) { + return; + } + } + + event.preventDefault(); + } + + normalizeButtonTypes(); + + const observer = new MutationObserver(() => { + normalizeButtonTypes(); + }); + + observer.observe(node, { childList: true, subtree: true }); + node.addEventListener('keydown', handleKeydown); + + return () => { + observer.disconnect(); + node.removeEventListener('keydown', handleKeydown); + }; +} + +export function createEmptyAlertPointsValue(): CwAlertPointsValue { + return { + unit: 'C', + center: '0', + points: [] + }; +} + +function mapAlertConditionToOperator(condition: CwAlertPointCondition): string { + switch (condition) { + case 'greaterThan': + return '>'; + case 'greaterThanOrEqual': + return '>='; + case 'lessThan': + return '<'; + case 'lessThanOrEqual': + return '<='; + case 'range': + return 'range'; + case 'equals': + default: + return '='; + } +} + +function mapOperatorToAlertCondition(operator: string | null | undefined): CwAlertPointCondition { + switch (operator) { + case '>': + return 'greaterThan'; + case '>=': + return 'greaterThanOrEqual'; + case '<': + return 'lessThan'; + case '<=': + return 'lessThanOrEqual'; + case 'range': + return 'range'; + case '=': + default: + return 'equals'; + } +} + +export function createReportDraftFactory({ + currentUser, + initialDevEui = '', + nextRowKey, + report +}: ReportDraftFactoryOptions) { + function createEmptySchedule(rootUserId = currentUser?.id ?? ''): ScheduleDraft { + return { + created_at: '', + dev_eui: '', + end_of_day: false, + end_of_week: true, + id: '', + is_active: true, + key: nextRowKey('schedule'), + report_id: report?.report_id ?? '', + report_user_schedule_id: '', + user_id: rootUserId + }; + } + + function createEmptyDataProcessingSchedule(): DataProcessingScheduleDraft { + return { + crosses_midnight: false, + day_of_week: '1', + end_time: '17:00', + id: '', + is_enabled: true, + key: nextRowKey('dps'), + report_id: report?.report_id ?? '', + rule_type: 'include', + start_time: '09:00', + timezone: 'JST' + }; + } + + function createEmptyRecipient(rootUserId = currentUser?.id ?? ''): RecipientDraft { + return { + communication_method: '1', + created_at: '', + email: currentUser?.email ?? '', + id: '', + key: nextRowKey('recipient'), + name: currentUser?.name ?? '', + report_id: report?.report_id ?? '', + user_id: rootUserId + }; + } + + function buildDefaultDraft(): ReportDraft { + return { + created_at: '', + data_pull_interval: DEFAULT_REPORT_DATA_PULL_INTERVAL, + dev_eui: normalizeDevEui(initialDevEui ?? ''), + id: '', + name: '', + report_id: '', + report_data_processing_schedules: [], + report_recipients: [createEmptyRecipient()], + report_user_schedule: [createEmptySchedule()], + user_id: currentUser?.id ?? '' + }; + } + + function buildDraftFromReport(source: ReportDto): ReportDraft { + const normalizedReportDevEui = normalizeDevEui(source.dev_eui ?? ''); + const rootUserId = cleanOptional(source.user_id ?? '') ?? currentUser?.id ?? ''; + const reportId = source.report_id ?? ''; + + const schedules = + source.report_user_schedule?.map((schedule) => { + const scheduleDevEui = normalizeDevEui(schedule.dev_eui ?? ''); + + return { + created_at: schedule.created_at ?? '', + dev_eui: scheduleDevEui === normalizedReportDevEui ? '' : scheduleDevEui, + end_of_day: schedule.end_of_day ?? false, + end_of_week: schedule.end_of_week ?? false, + id: schedule.id != null ? String(schedule.id) : '', + is_active: schedule.is_active ?? true, + key: nextRowKey('schedule'), + report_id: cleanOptional(schedule.report_id ?? '') ?? reportId, + report_user_schedule_id: + schedule.report_user_schedule_id != null + ? String(schedule.report_user_schedule_id) + : '', + user_id: cleanOptional(schedule.user_id ?? '') ?? rootUserId + }; + }) ?? []; + + const recipients = + source.report_recipients?.map((recipient) => ({ + communication_method: + recipient.communication_method != null ? String(recipient.communication_method) : '1', + created_at: recipient.created_at ?? '', + email: recipient.email ?? '', + id: recipient.id != null ? String(recipient.id) : '', + key: nextRowKey('recipient'), + name: recipient.name ?? '', + report_id: cleanOptional(recipient.report_id ?? '') ?? reportId, + user_id: cleanOptional(recipient.user_id ?? '') ?? rootUserId + })) ?? []; + + const dataProcessingSchedules = + source.report_data_processing_schedules?.map((schedule) => ({ + crosses_midnight: schedule.crosses_midnight ?? false, + day_of_week: schedule.day_of_week != null ? String(schedule.day_of_week) : '1', + end_time: schedule.end_time ?? '17:00', + id: schedule.id ?? '', + is_enabled: schedule.is_enabled ?? true, + key: nextRowKey('dps'), + report_id: schedule.report_id ?? reportId, + rule_type: schedule.rule_type ?? 'include', + start_time: schedule.start_time ?? '09:00', + timezone: schedule.timezone ?? 'UTC' + })) ?? []; + + return { + created_at: source.created_at ?? '', + data_pull_interval: normalizeReportDataPullInterval(source.data_pull_interval), + dev_eui: normalizedReportDevEui, + id: source.id != null ? String(source.id) : '', + name: source.name ?? '', + report_id: reportId, + report_data_processing_schedules: dataProcessingSchedules, + report_recipients: recipients.length > 0 ? recipients : [createEmptyRecipient(rootUserId)], + report_user_schedule: schedules.length > 0 ? schedules : [createEmptySchedule(rootUserId)], + user_id: rootUserId + }; + } + + function buildAlertPointsValueFromReport(source: ReportDto): CwAlertPointsValue { + const baseValue = createEmptyAlertPointsValue(); + const points = + source.report_alert_points?.map((point, index) => { + const condition = + typeof point.operator === 'string' && point.operator.trim().length > 0 + ? mapOperatorToAlertCondition(point.operator) + : point.min != null || point.max != null + ? 'range' + : 'equals'; + const fallbackId = point.id != null ? String(point.id) : nextRowKey(`alert-${index + 1}`); + + return { + color: cleanOptional(point.hex_color ?? '')?.toUpperCase() ?? DEFAULT_ALERT_COLOR, + condition, + id: fallbackId, + max: point.max != null ? String(point.max) : '', + min: point.min != null ? String(point.min) : '', + name: point.name ?? '', + value: point.value != null ? String(point.value) : '' + }; + }) ?? []; + + return { ...baseValue, points }; + } + + return { + buildAlertPointsValueFromReport, + buildDefaultDraft, + buildDraftFromReport, + createEmptyDataProcessingSchedule, + createEmptyRecipient + }; +} + +function buildReportAlertPoints( + alertPoints: CwAlertPointsValue, + defaults: { + reportId?: string; + userId?: string; + } +): CreateReportAlertPointRequest[] { + return alertPoints.points.map((point) => { + const alertPayload: CreateReportAlertPointRequest = { + data_point_key: DEFAULT_ALERT_DATA_POINT_KEY, + name: point.name.trim() + }; + + if (point.id !== undefined && point.id !== null && point.id !== '') { + const parsedId = Number(point.id); + if (!isNaN(parsedId)) { + alertPayload.id = parsedId; + } + } + + const alertHexColor = cleanOptional(point.color)?.toUpperCase(); + const alertMax = parseOptionalNumber(point.max); + const alertMin = parseOptionalNumber(point.min); + const alertOperator = cleanOptional(mapAlertConditionToOperator(point.condition)); + const alertValue = parseOptionalNumber(point.value); + + if (alertHexColor) alertPayload.hex_color = alertHexColor; + if (alertMax !== undefined) alertPayload.max = alertMax; + if (alertMin !== undefined) alertPayload.min = alertMin; + if (alertOperator) alertPayload.operator = alertOperator; + if (defaults.reportId) alertPayload.report_id = defaults.reportId; + if (defaults.userId) alertPayload.user_id = defaults.userId; + if (alertValue !== undefined) alertPayload.value = alertValue; + + return alertPayload; + }); +} + +export function buildRequestPayload( + draft: ReportDraft, + rootDevEui: string, + alertPoints: CwAlertPointsValue, + currentUser: CurrentUser +): CreateReportRequest { + const createdAt = cleanOptional(draft.created_at); + const id = parseOptionalInteger(draft.id); + const reportId = cleanOptional(draft.report_id); + const userId = cleanOptional(draft.user_id) ?? currentUser?.id ?? undefined; + const devEui = normalizeDevEui(rootDevEui); + const dataPullInterval = parseReportDataPullInterval(draft.data_pull_interval); + + const reportUserSchedule: CreateReportUserScheduleRequest[] = draft.report_user_schedule.map( + (schedule) => { + const schedulePayload: CreateReportUserScheduleRequest = { + dev_eui: normalizeDevEui(schedule.dev_eui) || devEui, + end_of_day: schedule.end_of_day, + end_of_week: schedule.end_of_week, + is_active: schedule.is_active + }; + + const scheduleCreatedAt = cleanOptional(schedule.created_at); + const scheduleId = parseOptionalInteger(schedule.id); + const scheduleReportId = cleanOptional(schedule.report_id) ?? reportId; + const scheduleRowId = parseOptionalInteger(schedule.report_user_schedule_id); + const scheduleUserId = cleanOptional(schedule.user_id) ?? userId; + + if (scheduleCreatedAt) schedulePayload.created_at = scheduleCreatedAt; + if (scheduleId !== undefined) schedulePayload.id = scheduleId; + if (scheduleReportId) schedulePayload.report_id = scheduleReportId; + if (scheduleRowId !== undefined) schedulePayload.report_user_schedule_id = scheduleRowId; + if (scheduleUserId) schedulePayload.user_id = scheduleUserId; + + return schedulePayload; + } + ); + + const reportAlertPoints = buildReportAlertPoints(alertPoints, { reportId, userId }); + + const reportRecipients: CreateReportRecipientRequest[] = draft.report_recipients.map( + (recipient) => { + const recipientPayload: CreateReportRecipientRequest = { + communication_method: parseOptionalInteger(recipient.communication_method) ?? 1 + }; + + const recipientCreatedAt = cleanOptional(recipient.created_at); + const recipientEmail = cleanOptional(recipient.email); + const recipientId = parseOptionalInteger(recipient.id); + const recipientName = cleanOptional(recipient.name); + const recipientReportId = cleanOptional(recipient.report_id) ?? reportId; + const recipientUserId = cleanOptional(recipient.user_id) ?? userId; + + if (recipientCreatedAt) recipientPayload.created_at = recipientCreatedAt; + if (recipientEmail) recipientPayload.email = recipientEmail; + if (recipientId !== undefined) recipientPayload.id = recipientId; + if (recipientName) recipientPayload.name = recipientName; + if (recipientReportId) recipientPayload.report_id = recipientReportId; + if (recipientUserId) recipientPayload.user_id = recipientUserId; + + return recipientPayload; + } + ); + + const reportDataProcessingSchedules: CreateReportDataProcessingScheduleRequest[] = + draft.report_data_processing_schedules + .filter((schedule) => schedule.day_of_week && schedule.start_time && schedule.end_time) + .map((schedule) => { + const entry: CreateReportDataProcessingScheduleRequest = { + day_of_week: parseInt(schedule.day_of_week, 10), + start_time: schedule.start_time, + end_time: schedule.end_time + }; + + if (schedule.crosses_midnight !== undefined) { + entry.crosses_midnight = schedule.crosses_midnight; + } + if (schedule.id) entry.id = schedule.id; + entry.is_enabled = schedule.is_enabled; + if (schedule.report_id) entry.report_id = schedule.report_id; + if (schedule.rule_type) entry.rule_type = schedule.rule_type; + if (schedule.timezone) entry.timezone = schedule.timezone; + + return entry; + }); + + const payload: CreateReportRequest = { + dev_eui: devEui, + name: draft.name.trim(), + report_alert_points: reportAlertPoints, + report_data_processing_schedules: reportDataProcessingSchedules, + report_recipients: reportRecipients, + report_user_schedule: reportUserSchedule + }; + + if (createdAt) payload.created_at = createdAt; + if (dataPullInterval !== undefined) payload.data_pull_interval = dataPullInterval; + if (id !== undefined) payload.id = id; + if (reportId) payload.report_id = reportId; + if (userId) payload.user_id = userId; + + return payload; +} + +function buildAlertValidationIssues(alertPoints: CwAlertPointsValue): string[] { + const issues: string[] = []; + + alertPoints.points.forEach((point, index) => { + const rowIndex = String(index + 1); + + if (!point.name.trim()) { + issues.push(m.reports_create_validation_alert_name({ index: rowIndex })); + } + + if (point.condition === 'range') { + const min = parseOptionalNumber(point.min); + const max = parseOptionalNumber(point.max); + + if (min === undefined || max === undefined) { + issues.push(m.reports_create_validation_alert_range_required({ index: rowIndex })); + } else if (min > max) { + issues.push(m.reports_create_validation_alert_range_order({ index: rowIndex })); + } + } else if (parseOptionalNumber(point.value) === undefined) { + issues.push(m.reports_create_validation_alert_value({ index: rowIndex })); + } + + const hexColor = cleanOptional(point.color); + if (hexColor && !/^#[0-9a-fA-F]{6}$/.test(hexColor)) { + issues.push(m.reports_create_validation_alert_hex({ index: rowIndex })); + } + }); + + return issues; +} + +export function buildValidationIssues( + draft: ReportDraft, + rootDevEui: string, + alertPoints: CwAlertPointsValue, + currentUser: CurrentUser +): string[] { + const issues: string[] = []; + const normalizedRootDevEui = normalizeDevEui(rootDevEui); + const rootUserId = cleanOptional(draft.user_id) ?? currentUser?.id ?? undefined; + + if (!draft.name.trim()) { + issues.push(m.reports_create_validation_report_name_required()); + } + + if (normalizedRootDevEui.length !== 16) { + issues.push(m.reports_create_validation_dev_eui_length()); + } + + draft.report_user_schedule.forEach((schedule, index) => { + const scheduleDevEui = normalizeDevEui(schedule.dev_eui) || normalizedRootDevEui; + const rowIndex = String(index + 1); + + if (scheduleDevEui.length !== 16) { + issues.push(m.reports_create_validation_schedule_dev_eui({ index: rowIndex })); + } + + if (schedule.is_active && !schedule.end_of_week && !schedule.end_of_day) { + issues.push(m.reports_create_validation_schedule_cadence({ index: rowIndex })); + } + }); + + issues.push(...buildAlertValidationIssues(alertPoints)); + + draft.report_recipients.forEach((recipient, index) => { + const rowIndex = String(index + 1); + const communicationMethod = parseOptionalInteger(recipient.communication_method); + const email = cleanOptional(recipient.email); + const userId = cleanOptional(recipient.user_id) ?? rootUserId; + + if (!communicationMethod) { + issues.push(m.reports_create_validation_recipient_method({ index: rowIndex })); + } + + if (!email && !userId) { + issues.push(m.reports_create_validation_recipient_destination({ index: rowIndex })); + } + + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + issues.push(m.reports_create_validation_recipient_email({ index: rowIndex })); + } + }); + + return issues; +} diff --git a/src/routes/rules-new/+page.server.ts b/src/routes/rules-new/+page.server.ts deleted file mode 100644 index 9b24be95..00000000 --- a/src/routes/rules-new/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageServerLoad } from './$types'; - -// The table fetches rule templates through the local /api/rules-new endpoint so -// search, paging, and deletes can refresh without duplicating list data in SSR. -export const load: PageServerLoad = async () => { - return {}; -}; diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte index 3e9dfb96..27b11a27 100644 --- a/src/routes/rules-new/+page.svelte +++ b/src/routes/rules-new/+page.svelte @@ -1,11 +1,13 @@ - + diff --git a/src/routes/rules/create/+page.svelte b/src/routes/rules/create/+page.svelte index e12f049b..a1da38f5 100644 --- a/src/routes/rules/create/+page.svelte +++ b/src/routes/rules/create/+page.svelte @@ -1,387 +1,13 @@ - - - - - - goto(resolve('/rules'))}> - ← {m.action_back()} - - -
- -
-

{m.rules_create_new_rule()}

-
- - - - - - -
- - - - -
- - - {#snippet leftSlot()} - 📧 - {/snippet} - -
-
- - - - - {#if deviceOptions.length === 0} - -

{m.rules_no_devices_available()}

-
- {:else} - - {#if deviceLocked} -

{m.rules_device_preselected()}

- {/if} - {#if selectedDevEui} -
- - {selectedDevEui} -
- {/if} - {/if} -
-
- - - - - {#each criteria as criterion, idx (criterion.id)} -
-
- {m.rules_condition_number({ count: String(idx + 1) })} - {#if criteria.length > 1} - removeCriterion(criterion.id)}> - {m.action_remove()} - - {/if} -
- -
- - - -
- -
-
- -
-
-
- {/each} - - - {m.rules_add_another_condition()} - -
-
- - - - - {#if isFormValid} - -
-
{m.common_name()}:
-
{ruleName}
-
{m.devices_device()}:
-
{selectedDeviceName}
-
{m.rules_notify_via()}:
-
- {NOTIFIER_TYPES.find((n) => n.value === notifierType)?.label} - ({sendUsing}) → {actionRecipient} -
-
{m.rules_conditions()}:
-
-
- {#each criteriaPreview as preview, i (i)} - - {/each} -
-
-
-
- {:else} - -

{m.rules_complete_required_fields()}

-
- {/if} - - - - - goto(resolve('/rules'))} disabled={submitting}> - {m.action_cancel()} - - - {submitting ? m.rules_creating() : m.rules_create_rule()} - - -
-
-
-
- - + diff --git a/src/routes/rules/edit/[id]/+page.svelte b/src/routes/rules/edit/[id]/+page.svelte index 179f4f33..2ce7ea78 100644 --- a/src/routes/rules/edit/[id]/+page.svelte +++ b/src/routes/rules/edit/[id]/+page.svelte @@ -1,394 +1,8 @@ - - - - - -
- -
- goto(resolve('/rules'))}> - {m.action_back()} - -

{m.rules_edit_rule()}

- -
- - - - - - -
- - -
- - - {#snippet leftSlot()} - 📧 - {/snippet} - -
-
- - - - - {#if deviceOptions.length === 0} - -

{m.rules_no_devices_available()}

-
- {:else} - - {#if selectedDevEui} -
- - {selectedDevEui} -
- {/if} - {/if} -
-
- - - - - {#each criteria as criterion, idx (criterion.id)} -
-
- {m.rules_condition_number({ count: String(idx + 1) })} - {#if criteria.length > 1} - removeCriterion(criterion.id)}> - {m.action_remove()} - - {/if} -
- -
- - - -
- -
-
- -

- {m.rules_reset_value_help()} -

-
-
-
- {/each} - -
-
- - - - - {#if isFormValid} - -
-
{m.common_name()}:
-
{ruleName}
-
{m.devices_device()}:
-
{selectedDeviceName}
-
{m.rules_notify_via()}:
-
- {NOTIFIER_TYPES.find((n) => n.value === notifierType)?.label} - ({sendUsing}) → {actionRecipient} -
-
{m.rules_conditions()}:
-
-
- {#each criteriaPreview as preview, i (i)} - - {/each} -
-
-
-
- {:else} - -

{m.rules_complete_required_fields()}

-
- {/if} - - - - - goto(resolve('/rules'))} disabled={submitting}> - {m.action_cancel()} - - - - {submitting ? m.action_saving() : m.action_save_changes()} - - -
-
-
-
- - + From 328315159367f1c190884aa37808d05a79b91e43 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 3 May 2026 01:20:00 +0900 Subject: [PATCH 4/7] getting relay alert action working! --- messages/en.json | 9 + messages/ja.json | 9 + package.json | 2 +- pnpm-lock.yaml | 10 +- src/lib/api/api.dtos.ts | 1 - .../rule-actions/Relay-Actions.svelte | 141 ++++++ src/routes/rules-new/+page.svelte | 1 - src/routes/rules-new/RuleTemplateForm.svelte | 401 +++++++----------- .../rules-new/rule-template-alert-points.ts | 18 + 9 files changed, 344 insertions(+), 248 deletions(-) create mode 100644 src/lib/components/rule-actions/Relay-Actions.svelte diff --git a/messages/en.json b/messages/en.json index 7a0e1fbe..dba144ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -487,6 +487,15 @@ "reports_create_alert_points_description_greater_than": "> {value} {unit}", "reports_create_alert_points_description_greater_than_or_equal": ">= {value} {unit}", "reports_create_alert_points_overlap_error": "Overlaps with {labels}. Each alert must cover a unique part of the number line.", + "reports_create_alert_points_reset_never_happens_preview_single": "1 reset will never happen because every reset value is covered by another alert.", + "reports_create_alert_points_reset_never_happens_preview_multiple": "{count} resets will never happen because every reset value is covered by another alert.", + "reports_create_alert_points_reset_never_happens_error": "Reset will never happen because every reset value is covered by another alert.", + "reports_create_alert_points_reset_waiting_for_value": "Reset waiting for a value.", + "reports_create_alert_points_reset_description_not_equals": "Reset != {value} {unit}", + "reports_create_alert_points_reset_description_less_than": "Reset < {value} {unit}", + "reports_create_alert_points_reset_description_less_than_or_equal": "Reset <= {value} {unit}", + "reports_create_alert_points_reset_description_greater_than": "Reset > {value} {unit}", + "reports_create_alert_points_reset_description_greater_than_or_equal": "Reset >= {value} {unit}", "reports_create_alert_user_label": "User ID (optional)", "reports_create_alert_user_placeholder": "Leave blank unless needed", "reports_create_advanced_alert_metadata": "System fields", diff --git a/messages/ja.json b/messages/ja.json index 885b03dc..77f9dcd5 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -487,6 +487,15 @@ "reports_create_alert_points_description_greater_than": "> {value} {unit}", "reports_create_alert_points_description_greater_than_or_equal": ">= {value} {unit}", "reports_create_alert_points_overlap_error": "{labels} と範囲が重複しています。各条件が数直線上で重ならないように値を調整してください。", + "reports_create_alert_points_reset_never_happens_preview_single": "1 件のリセットは、他の条件によってリセット値が完全に覆われているため発生しません。", + "reports_create_alert_points_reset_never_happens_preview_multiple": "{count} 件のリセットは、他の条件によってリセット値が完全に覆われているため発生しません。", + "reports_create_alert_points_reset_never_happens_error": "他の条件によってリセット値が完全に覆われているため、リセットは発生しません。", + "reports_create_alert_points_reset_waiting_for_value": "リセット値の入力待ちです。", + "reports_create_alert_points_reset_description_not_equals": "リセット != {value} {unit}", + "reports_create_alert_points_reset_description_less_than": "リセット < {value} {unit}", + "reports_create_alert_points_reset_description_less_than_or_equal": "リセット <= {value} {unit}", + "reports_create_alert_points_reset_description_greater_than": "リセット > {value} {unit}", + "reports_create_alert_points_reset_description_greater_than_or_equal": "リセット >= {value} {unit}", "reports_create_alert_user_label": "ユーザー ID(任意)", "reports_create_alert_user_placeholder": "必要な場合のみ入力", "reports_create_advanced_alert_metadata": "システム項目", diff --git a/package.json b/package.json index 3d050c48..df3b17d2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.88", + "@cropwatchdevelopment/cwui": "0.1.89", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fea5a797..fa119574 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.88 - version: 0.1.88(svelte@5.53.0) + specifier: 0.1.89 + version: 0.1.89(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.88': - resolution: {integrity: sha512-GHWImrFRt4lS4SRp2M4+B4yuNHqc3EvnYeuUaEY1bzb05B7tCpv2SWTEG2MapoPn7UCNbh/MMPTotoLwm2mqIA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.88/298733ca9370b797d09aad327f0bd24c91ce1a73} + '@cropwatchdevelopment/cwui@0.1.89': + resolution: {integrity: sha512-OEYkmUUKk8/0cdH+0kA3v3cOQ/7Uh5ZvBn4EbrMBvFAXllEyMIxhWeE0RInBbjpOq98MrbqEuEnaiodO4/G8Bg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.89/1e037d65466dabe264fe37ca120e567b158e8cd5} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.88(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.89(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index 9ade4228..3275eb6e 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -209,7 +209,6 @@ export interface RuleTemplateActionDto { export interface RuleActionTypeDto { id: number; - actionTypeId: number; name: string; value?: string | null; createdAt: string; diff --git a/src/lib/components/rule-actions/Relay-Actions.svelte b/src/lib/components/rule-actions/Relay-Actions.svelte new file mode 100644 index 00000000..cdf42933 --- /dev/null +++ b/src/lib/components/rule-actions/Relay-Actions.svelte @@ -0,0 +1,141 @@ + + +
+ + + + + +
\ No newline at end of file diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte index 27b11a27..7560de0d 100644 --- a/src/routes/rules-new/+page.svelte +++ b/src/routes/rules-new/+page.svelte @@ -213,7 +213,6 @@ {#snippet toolbarActions()} goto(resolve('/rules-new/create'))}> - {m.rules_new_create_template()} {/snippet} diff --git a/src/routes/rules-new/RuleTemplateForm.svelte b/src/routes/rules-new/RuleTemplateForm.svelte index e5040b19..b931799d 100644 --- a/src/routes/rules-new/RuleTemplateForm.svelte +++ b/src/routes/rules-new/RuleTemplateForm.svelte @@ -18,7 +18,6 @@ CwAlertPointsEditor, CwButton, CwCard, - CwChip, CwDropdown, CwInput, CwMultiSelect, @@ -38,6 +37,7 @@ ensureAlertPointsIncludeReset, hasUnsupportedAlertPointConditions } from './rule-template-alert-points'; + import RelayActions from '$lib/components/rule-actions/Relay-Actions.svelte'; type FormMode = 'create' | 'edit'; @@ -50,28 +50,24 @@ preselectedDevEui?: string | null; } - interface AssignmentEntry { - localId: number; - devices: DeviceSelection[]; - } - interface DeviceSelection { id: string; label: string; } - interface ActionEntry { - localId: number; - persistedId: number | null; - actionTypeId: string; - actionTypeLabel: string | null; + interface RuleTemplateActionConfig { recipient: string; + [key: string]: Json | undefined; } + type EditableTemplateAction = Omit & { + config: RuleTemplateActionConfig; + }; + let { mode, devices, - actionTypes, + actionTypes: actions, authToken = null, initialTemplate = null, preselectedDevEui = null @@ -89,43 +85,31 @@ let isActive = $state(initial?.isActive ?? true); let submitting = $state(false); - let nextAssignmentId = $state((initial?.assignments.length ?? 0) + 1); - let assignments = $state( + let selectedDevices = $state( initial?.assignments.length - ? initial.assignments.map((assignment, index) => ({ - localId: index + 1, - devices: [createDeviceSelection(assignment.devEui)] - })) - : [ - { - localId: 1, - devices: preselectedDevice ? [createDeviceSelection(preselectedDevice)] : [] - } - ] + ? createDeviceSelections(initial.assignments.map((assignment) => assignment.devEui)) + : preselectedDevice + ? [createDeviceSelection(preselectedDevice)] + : [] ); const initialCriteriaGroups = buildInitialAlertCriteriaGroups(initial?.criteria ?? []); let nextCriteriaGroupId = $state(initialCriteriaGroups.length + 1); let criteriaGroups = $state(initialCriteriaGroups); - let nextActionId = $state((initial?.actions.length ?? 0) + 1); - let actions = $state( + let templateActions = $state( initial?.actions.length - ? initial.actions.map((action, index) => ({ - localId: index + 1, - persistedId: action.id, - actionTypeId: String(action.actionType), - actionTypeLabel: action.actionTypeName ?? action.actionTypeValue ?? null, - recipient: readActionRecipient(action) - })) - : [createBlankAction(1)] + ? initial.actions.map(createEditableTemplateAction) + : [createBlankTemplateAction()] ); let actionTypeOptions = $derived( - (actionTypes ?? []).map((actionType) => ({ - label: actionType.name, - value: String(actionType.actionTypeId) - })) + actions + .filter((actionType) => isValidActionTypeId(actionType.id)) + .map((actionType) => ({ + label: actionType.name, + value: String(actionType.id) + })) ); let deviceOptionsBase = $derived( (devices ?? []).map((device) => ({ @@ -133,123 +117,77 @@ value: device.dev_eui })) ); - - let selectedDevEuis = $derived( - assignments - .flatMap((assignment) => assignment.devices.map((device) => device.id.trim())) - .filter(Boolean) - ); - let hasDuplicateDeviceAssignments = $derived(hasDuplicateValues(selectedDevEuis)); + let deviceOptions = $derived([ + ...selectedDevices + .filter((device) => !deviceOptionsBase.some((option) => option.value === device.id)) + .map((device) => ({ + label: device.label || getDeviceLabel(device.id), + value: device.id + })), + ...deviceOptionsBase + ]); + + let selectedDevEuis = $derived(selectedDevices.map((device) => device.id.trim()).filter(Boolean)); let selectedDeviceTypeId = $derived(resolveSelectedDeviceTypeId()); let criteriaForTest = $derived(buildRuleTemplateCriteriaFromAlertGroups(criteriaGroups)); let hasUnsupportedCriteria = $derived(hasUnsupportedAlertPointConditions(criteriaGroups)); let isFormValid = $derived( ruleName.trim().length > 0 && selectedDevEuis.length > 0 && - !hasDuplicateDeviceAssignments && !hasUnsupportedCriteria && areAlertCriteriaGroupsValid(criteriaGroups) && - actions.every(isActionValid) + templateActions.every( + (action) => + isValidActionTypeId(action.actionType) && action.config.recipient.trim().length > 0 + ) + ); + let assignmentSummary = $derived( + selectedDevices + .map((device) => device.label || getDeviceLabel(device.id)) + .filter(Boolean) + .join(', ') ); - let assignmentPreview = $derived(selectedDevEuis.map(getDeviceLabel)); let criteriaPreview = $derived( criteriaForTest.map((criterion) => { const label = SUBJECT_OPTIONS.find((option) => option.value === criterion.subject)?.label; const operatorLabel = OPERATORS.find((option) => option.value === criterion.operator)?.label ?? criterion.operator; - return `${label ?? criterion.subject} ${operatorLabel} ${criterion.triggerValue}`; + return `${label ?? criterion.subject} ${operatorLabel} ${criterion.triggerValue} (${m.rules_reset_value()}: ${criterion.resetValue})`; }) ); + let criteriaSummary = $derived(criteriaPreview.join(', ')); let actionPreview = $derived( - actions.map((action) => { - return `${getActionTypeLabel(action)}: ${action.recipient}`; + templateActions.map((action) => { + return `${getActionTypeLabel(action)}: ${action.config.recipient}`; }) ); + let actionSummary = $derived(actionPreview.join(', ')); + + function createBlankTemplateAction(): EditableTemplateAction { + const actionType = actions[0]; - function createBlankAction(localId: number): ActionEntry { return { - localId, - persistedId: null, - actionTypeId: String(actionTypes[0]?.actionTypeId ?? ''), - actionTypeLabel: actionTypes[0]?.name ?? null, - recipient: '' + id: 0, + templateId: initial?.id ?? 0, + actionType: actionType?.id ?? 0, + actionTypeName: actionType?.name ?? null, + actionTypeValue: actionType?.value ?? null, + config: { + recipient: '' + }, + createdAt: null }; } - function addAssignment() { - assignments = [...assignments, { localId: nextAssignmentId, devices: [] }]; - nextAssignmentId += 1; - } - - function removeAssignment(localId: number) { - if (assignments.length <= 1) return; - assignments = assignments.filter((assignment) => assignment.localId !== localId); - } - - function addAction() { - actions = [...actions, createBlankAction(nextActionId)]; - nextActionId += 1; - } - - function removeAction(localId: number) { - if (actions.length <= 1) return; - actions = actions.filter((action) => action.localId !== localId); - } - - function addCriteriaGroup() { - criteriaGroups = [...criteriaGroups, createBlankAlertCriteriaGroup(nextCriteriaGroupId)]; - nextCriteriaGroupId += 1; - } - - function removeCriteriaGroup(localId: number) { - if (criteriaGroups.length <= 1) return; - criteriaGroups = criteriaGroups.filter((group) => group.localId !== localId); - } - - function updateCriteriaGroupAlertPoints( - localId: number, - alertPoints: (typeof criteriaGroups)[number]['alertPoints'] - ) { - const nextAlertPoints = ensureAlertPointsIncludeReset(alertPoints); - criteriaGroups = criteriaGroups.map((group) => - group.localId === localId - ? { - ...group, - alertPoints: nextAlertPoints - } - : group - ); - } - - function deviceOptionsFor(currentAssignment: AssignmentEntry) { - const selectedByOtherRows = assignments - .filter((assignment) => assignment.localId !== currentAssignment.localId) - .flatMap((assignment) => assignment.devices.map((device) => device.id)) - .filter(Boolean); - - const options = deviceOptionsBase.map((option) => ({ - ...option, - disabled: selectedByOtherRows.includes(option.value) - })); - - if ( - currentAssignment.devices.some( - (device) => !options.some((option) => option.value === device.id) - ) - ) { - return [ - ...currentAssignment.devices - .filter((device) => !options.some((option) => option.value === device.id)) - .map((device) => ({ - label: device.label, - value: device.id - })), - ...options - ]; - } - - return options; + function createEditableTemplateAction(action: RuleTemplateActionDto): EditableTemplateAction { + return { + ...action, + config: { + ...readActionConfig(action.config), + recipient: readActionRecipient(action) + } + }; } function createDeviceSelection(devEui: string): DeviceSelection { @@ -259,6 +197,16 @@ }; } + function createDeviceSelections(devEuis: string[]): DeviceSelection[] { + const seen: string[] = []; + return devEuis.flatMap((devEui) => { + const id = devEui.trim(); + if (!id || seen.includes(id)) return []; + seen.push(id); + return [createDeviceSelection(id)]; + }); + } + function getDeviceLabel(devEui: string): string { const device = devices.find((entry) => entry.dev_eui === devEui); return device?.name ? `${device.name} (${devEui})` : devEui; @@ -280,14 +228,6 @@ return types.length === 1 ? types[0] : null; } - function hasDuplicateValues(values: string[]): boolean { - return values.some((value, index) => values.indexOf(value) !== index); - } - - function isActionValid(action: ActionEntry): boolean { - return parseActionTypeId(action.actionTypeId) !== null && action.recipient.trim().length > 0; - } - function buildPayload(): RuleTemplateSaveRequest { return { name: ruleName.trim(), @@ -296,21 +236,21 @@ isActive, devEuis: selectedDevEuis, criteria: criteriaForTest, - actions: actions.map(buildActionPayload) + actions: templateActions.map(buildActionPayload) }; } - function buildActionPayload(action: ActionEntry): RuleTemplateActionInput { - const actionType = parseActionTypeId(action.actionTypeId); - if (actionType === null) { + function buildActionPayload(action: EditableTemplateAction): RuleTemplateActionInput { + if (!isValidActionTypeId(action.actionType)) { throw new Error('Action type is required.'); } return { - id: action.persistedId, - actionType, + id: action.id > 0 ? action.id : null, + actionType: action.actionType, config: { - recipient: action.recipient.trim() + ...action.config, + recipient: action.config.recipient.trim() } }; } @@ -354,7 +294,7 @@ } function readActionRecipient(action: RuleTemplateActionDto): string { - const config: Record = isRecord(action.config) ? action.config : {}; + const config = readActionConfig(action.config); return ( readString(config.recipient) ?? readString(config.action_recipient) ?? @@ -363,34 +303,50 @@ ); } - function actionTypeOptionsFor(action: ActionEntry) { + function readActionConfig(config: Json): Record { + return isRecord(config) ? config : {}; + } + + function actionTypeOptionsFor(action: EditableTemplateAction) { + const selectedValue = String(action.actionType); + if ( - !action.actionTypeId || - actionTypeOptions.some((option) => option.value === action.actionTypeId) + !isValidActionTypeId(action.actionType) || + actionTypeOptions.some((option) => option.value === selectedValue) ) { return actionTypeOptions; } return [ { - label: action.actionTypeLabel ?? action.actionTypeId, - value: action.actionTypeId + label: action.actionTypeName ?? action.actionTypeValue ?? selectedValue, + value: selectedValue }, ...actionTypeOptions ]; } - function getActionTypeLabel(action: ActionEntry): string { + function selectActionType(action: EditableTemplateAction, value: string) { + const selectedAction = actions.find((entry) => String(entry.id) === value); + const parsedActionType = Number(value); + + action.actionType = + selectedAction?.id ?? (Number.isInteger(parsedActionType) ? parsedActionType : 0); + action.actionTypeName = selectedAction?.name ?? null; + action.actionTypeValue = selectedAction?.value ?? null; + } + + function getActionTypeLabel(action: EditableTemplateAction): string { return ( - actionTypes.find((entry) => String(entry.actionTypeId) === action.actionTypeId)?.name ?? - action.actionTypeLabel ?? - action.actionTypeId + actions.find((entry) => entry.id === action.actionType)?.name ?? + action.actionTypeName ?? + action.actionTypeValue ?? + String(action.actionType) ); } - function parseActionTypeId(value: string): number | null { - const id = Number(value); - return Number.isInteger(id) && id > 0 ? id : null; + function isValidActionTypeId(value: number): boolean { + return Number.isInteger(value) && value > 0; } function readString(value: Json | undefined): string | null { @@ -427,50 +383,21 @@ - {#if deviceOptionsBase.length === 0} + {#if deviceOptions.length === 0}

{m.rules_no_devices_available()}

{:else} - {#each assignments as assignment, index (assignment.localId)} -
-
- {m.rules_new_assignment_number({ count: String(index + 1) })} - {#if assignments.length > 1} - removeAssignment(assignment.localId)} - > - {m.action_remove()} - - {/if} -
- -
- {/each} - - {#if hasDuplicateDeviceAssignments} - -

{m.rules_new_duplicate_devices()}

-
- {/if} - - = deviceOptionsBase.length} - > - {m.rules_new_add_device_assignment()} - +
+ +
{/if}
@@ -482,7 +409,7 @@
{m.rules_condition_number({ count: String(index + 1) })} {#if criteriaGroups.length > 1} - removeCriteriaGroup(group.localId)}> + criteriaGroups.splice(index, 1)}> {m.action_remove()} {/if} @@ -495,8 +422,8 @@ required /> updateCriteriaGroupAlertPoints(group.localId, value)} + bind:value={group.alertPoints} + onchange={(value) => (group.alertPoints = ensureAlertPointsIncludeReset(value))} text={alertPointsEditorText} />
@@ -508,7 +435,14 @@ {/if} - + { + criteriaGroups.push(createBlankAlertCriteriaGroup(nextCriteriaGroupId)); + nextCriteriaGroupId += 1; + }} + > {m.rules_add_another_condition()} @@ -516,12 +450,12 @@ - {#each actions as action, index (action.localId)} + {#each templateActions as action, index (action)}
{m.rules_new_action_number({ count: String(index + 1) })} - {#if actions.length > 1} - removeAction(action.localId)}> + {#if templateActions.length > 1} + templateActions.splice(index, 1)}> {m.action_remove()} {/if} @@ -531,20 +465,31 @@ - selectActionType(action, value)} required /> + {#if action.actionTypeName === 'EMail'} + + {:else if action.actionTypeName === 'LoRaWAN'} + + {/if}
{/each} - + { + templateActions.push(createBlankTemplateAction()); + }} + > {m.rules_new_add_action()}
@@ -562,29 +507,11 @@
{m.rules_new_status()}:
{isActive ? m.rules_new_active() : m.rules_new_inactive()}
{m.rules_new_assigned_devices()}:
-
-
- {#each assignmentPreview as assignment (assignment)} - - {/each} -
-
+
{assignmentSummary}
{m.rules_conditions()}:
-
-
- {#each criteriaPreview as item, index (item + index)} - - {/each} -
-
+
{criteriaSummary}
{m.rules_new_actions()}:
-
-
- {#each actionPreview as action (action)} - - {/each} -
-
+
{actionSummary}
{:else} @@ -657,12 +584,6 @@ min-width: 0; } - .rules-new-form__chip-list { - display: flex; - flex-wrap: wrap; - gap: var(--cw-space-1); - } - @media (min-width: 640px) { .rules-new-form__actions-grid { grid-template-columns: minmax(10rem, 0.8fr) minmax(0, 1.2fr); diff --git a/src/routes/rules-new/rule-template-alert-points.ts b/src/routes/rules-new/rule-template-alert-points.ts index 51aabb9a..82499790 100644 --- a/src/routes/rules-new/rule-template-alert-points.ts +++ b/src/routes/rules-new/rule-template-alert-points.ts @@ -52,6 +52,13 @@ export function createRuleTemplateAlertPointsEditorText(): CwAlertPointsEditorTe count === 1 ? m.reports_create_alert_points_overlap_preview_single() : m.reports_create_alert_points_overlap_preview_multiple({ count: String(count) }), + resetNeverHappensPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_reset_never_happens_preview_single() + : m.reports_create_alert_points_reset_never_happens_preview_multiple({ + count: String(count) + }), + resetNeverHappensError: m.reports_create_alert_points_reset_never_happens_error(), minEqualsMaxWarning: m.reports_create_alert_points_equal_bounds_warning(), defaultPointName: (index) => m.rules_condition_number({ count: String(index) }), unitCelsiusLabel: '°C', @@ -79,6 +86,17 @@ export function createRuleTemplateAlertPointsEditorText(): CwAlertPointsEditorTe m.reports_create_alert_points_description_greater_than({ value, unit }), pointDescriptionGreaterThanOrEqual: (value, unit) => m.reports_create_alert_points_description_greater_than_or_equal({ value, unit }), + resetDescriptionWaitingForValue: m.reports_create_alert_points_reset_waiting_for_value(), + resetDescriptionNotEquals: (value, unit) => + m.reports_create_alert_points_reset_description_not_equals({ value, unit }), + resetDescriptionLessThan: (value, unit) => + m.reports_create_alert_points_reset_description_less_than({ value, unit }), + resetDescriptionLessThanOrEqual: (value, unit) => + m.reports_create_alert_points_reset_description_less_than_or_equal({ value, unit }), + resetDescriptionGreaterThan: (value, unit) => + m.reports_create_alert_points_reset_description_greater_than({ value, unit }), + resetDescriptionGreaterThanOrEqual: (value, unit) => + m.reports_create_alert_points_reset_description_greater_than_or_equal({ value, unit }), overlapError: (labels) => m.reports_create_alert_points_overlap_error({ labels: formatOverlapLabels(labels) }) }; From db82dac88ff410dc3b0f5fbdfa72d04425bd1ae0 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 3 May 2026 01:40:02 +0900 Subject: [PATCH 5/7] new rules ready for rule runner testing!!! --- .../rule-actions/Relay-Actions.svelte | 26 ++++++++++++++++--- src/routes/rules-new/RuleTemplateForm.svelte | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/lib/components/rule-actions/Relay-Actions.svelte b/src/lib/components/rule-actions/Relay-Actions.svelte index cdf42933..c06520cd 100644 --- a/src/lib/components/rule-actions/Relay-Actions.svelte +++ b/src/lib/components/rule-actions/Relay-Actions.svelte @@ -34,9 +34,29 @@ let { devices = [], resultBase64 = $bindable(), resultFPort = $bindable(), resultJson = $bindable() }: Props = $props(); - let selectedDeviceDevEui = $state(''); - let selectedAction = $state('ro1_on_timed'); - let onTimeSeconds = $state(5); + const initialResult = parseInitialResult(resultJson); + + let selectedDeviceDevEui = $state(initialResult?.devEui ?? ''); + let selectedAction = $state( + isActionValue(initialResult?.action) ? initialResult.action : 'ro1_on_timed' + ); + let onTimeSeconds = $state( + typeof initialResult?.onTimeSeconds === 'number' ? initialResult.onTimeSeconds : 5 + ); + + function parseInitialResult(json: string | undefined): Partial | null { + if (!json) return null; + try { + const parsed = JSON.parse(json); + return parsed && typeof parsed === 'object' ? (parsed as Partial) : null; + } catch { + return null; + } + } + + function isActionValue(value: unknown): value is ActionOption['value'] { + return value === 'ro1_on_timed' || value === 'ro2_on_timed' || value === 'both_on_timed'; + } let actionOptions: ActionOption[] = $derived([ { label: `Relay 1 ON for ${onTimeSeconds} seconds`, value: 'ro1_on_timed' }, diff --git a/src/routes/rules-new/RuleTemplateForm.svelte b/src/routes/rules-new/RuleTemplateForm.svelte index b931799d..d1efe149 100644 --- a/src/routes/rules-new/RuleTemplateForm.svelte +++ b/src/routes/rules-new/RuleTemplateForm.svelte @@ -477,7 +477,7 @@ required /> {:else if action.actionTypeName === 'LoRaWAN'} - + {/if} From ba1d7b7639dab30b758d25cc8a2d13bad1bffe5a Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 13 May 2026 22:34:08 +0900 Subject: [PATCH 6/7] working! --- messages/en.json | 8 +- messages/ja.json | 8 +- package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardDeviceTable.svelte | 39 ++++-- .../dashboard/dashboard-device-data.spec.ts | 41 ++++++- .../dashboard/dashboard-device-data.ts | 111 +++++++++++++----- .../components/dashboard/device-cards.spec.ts | 87 ++++++++++---- src/lib/components/dashboard/device-cards.ts | 109 ++++++++--------- .../displays/AirDisplay/AirDisplay.svelte | 4 +- .../rule-actions/Relay-Actions.svelte | 38 ++++-- src/routes/+layout.svelte | 3 - src/routes/+page.server.ts | 9 +- src/routes/Sidebar.svelte | 83 +++++++------ .../[dev_eui]/DeviceDashboardHeader.svelte | 6 +- .../{ => dialogs}/NotesReviewDialog.svelte | 0 .../ViewNoteHistoryDialog.svelte | 0 .../{ => dialogs}/csvExportDialog.svelte | 2 +- .../csvTrafficExportDialog.svelte | 2 +- 19 files changed, 361 insertions(+), 201 deletions(-) rename src/routes/locations/[location_id]/devices/[dev_eui]/{ => dialogs}/NotesReviewDialog.svelte (100%) rename src/routes/locations/[location_id]/devices/[dev_eui]/{ => dialogs}/ViewNoteHistoryDialog.svelte (100%) rename src/routes/locations/[location_id]/devices/[dev_eui]/{ => dialogs}/csvExportDialog.svelte (99%) rename src/routes/locations/[location_id]/devices/[dev_eui]/{ => dialogs}/csvTrafficExportDialog.svelte (98%) diff --git a/messages/en.json b/messages/en.json index dba144ac..24fe3d9f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -980,5 +980,11 @@ "gateways_public": "Public", "gateways_online": "Online", "gateways_offline": "Offline", - "gateways_private": "Private" + "gateways_private": "Private", + "rule_action_forever_acknowledge_title": "Confirm Permanent Device State", + "rule_action_forever_acknowledge_message": "This action will set the device to the chosen state and leave it there indefinitely. The device will not return to its previous state on its own.", + "rule_action_forever_acknowledge_responsibility": "You are responsible for periodically verifying that this device remains in the correct state. CropWatch will not change it back for you.", + "rule_action_forever_acknowledge_option": "I understand and accept responsibility for monitoring this device.", + "rule_action_forever_acknowledge_confirm": "I Understand, Continue", + "rule_action_revert_on_reset": "Revert relay state when the rule resets" } diff --git a/messages/ja.json b/messages/ja.json index 77f9dcd5..1c3904b9 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -980,5 +980,11 @@ "gateways_public": "公開", "gateways_online": "オンライン", "gateways_offline": "オフライン", - "gateways_private": "非公開" + "gateways_private": "非公開", + "rule_action_forever_acknowledge_title": "デバイスの永続的な状態を確認", + "rule_action_forever_acknowledge_message": "このアクションを実行すると、デバイスは指定された状態に設定され、その状態のまま無期限に維持されます。デバイスが自動的に元の状態に戻ることはありません。", + "rule_action_forever_acknowledge_responsibility": "このデバイスが正しい状態にあることを定期的に確認する責任は、お客様にあります。CropWatchが自動で元に戻すことはありません。", + "rule_action_forever_acknowledge_option": "このデバイスの監視責任を理解し、引き受けます。", + "rule_action_forever_acknowledge_confirm": "理解しました、続行", + "rule_action_revert_on_reset": "ルールがリセットされた時にリレーの状態を元に戻す" } diff --git a/package.json b/package.json index df3b17d2..5e8b7130 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.89", + "@cropwatchdevelopment/cwui": "0.1.91", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa119574..a7c74731 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.89 - version: 0.1.89(svelte@5.53.0) + specifier: 0.1.91 + version: 0.1.91(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.89': - resolution: {integrity: sha512-OEYkmUUKk8/0cdH+0kA3v3cOQ/7Uh5ZvBn4EbrMBvFAXllEyMIxhWeE0RInBbjpOq98MrbqEuEnaiodO4/G8Bg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.89/1e037d65466dabe264fe37ca120e567b158e8cd5} + '@cropwatchdevelopment/cwui@0.1.91': + resolution: {integrity: sha512-jQPv6pd68wNi4Dw8+/RBN/H/YRrh4jCDj37wFv29Wm0sCyxUdd6Ft3blV5Mdw9Yjztmy8tJtp4/L+KDHLGhCtw==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.91/60f865bffc9b428943b942c5432e151b93992b20} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.89(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.91(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/components/dashboard/DashboardDeviceTable.svelte b/src/lib/components/dashboard/DashboardDeviceTable.svelte index 02c904ea..3b1c0a94 100644 --- a/src/lib/components/dashboard/DashboardDeviceTable.svelte +++ b/src/lib/components/dashboard/DashboardDeviceTable.svelte @@ -17,7 +17,7 @@ import type { IDevice } from '$lib/interfaces/device.interface'; import CHECK_CIRCLE_ICON from '$lib/images/icons/check_circle.svg'; import ALERT_ICON from '$lib/images/icons/active_alert.svg'; - import { mapDashboardPrimaryDataToDevice } from './dashboard-device-data'; + import { mapDashboardPrimaryDataToDevice, uniqueDashboardDevices } from './dashboard-device-data'; import { DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES, isDashboardDeviceOffline, @@ -117,7 +117,7 @@ { signal: query.signal } ); - let devices = (result.data ?? []).map(mapDashboardPrimaryDataToDevice); + let devices = uniqueDashboardDevices((result.data ?? []).map(mapDashboardPrimaryDataToDevice)); const total = typeof result.total === 'number' ? result.total : devices.length; if (query.sort) { @@ -128,24 +128,39 @@ } function sortDevices(devices: IDevice[], column: string, direction: 'asc' | 'desc'): IDevice[] { - const numericColumns = new Set(['co2', 'humidity', 'temperature_c', 'soil_humidity', 'alert_count']); + const numericColumns = new Set([ + 'co2', + 'humidity', + 'temperature_c', + 'soil_humidity', + 'alert_count' + ]); const dir = direction === 'asc' ? 1 : -1; return [...devices].sort((a, b) => { - const aVal = column === 'created_at' - ? new Date(a.created_at).getTime() - : (a as unknown as Record)[column]; - const bVal = column === 'created_at' - ? new Date(b.created_at).getTime() - : (b as unknown as Record)[column]; + const aVal = + column === 'created_at' + ? new Date(a.created_at).getTime() + : (a as unknown as Record)[column]; + const bVal = + column === 'created_at' + ? new Date(b.created_at).getTime() + : (b as unknown as Record)[column]; if (numericColumns.has(column) || column === 'created_at') { - const aNum = typeof aVal === 'number' && Number.isFinite(aVal) ? aVal : Number.NEGATIVE_INFINITY; - const bNum = typeof bVal === 'number' && Number.isFinite(bVal) ? bVal : Number.NEGATIVE_INFINITY; + const aNum = + typeof aVal === 'number' && Number.isFinite(aVal) ? aVal : Number.NEGATIVE_INFINITY; + const bNum = + typeof bVal === 'number' && Number.isFinite(bVal) ? bVal : Number.NEGATIVE_INFINITY; return (aNum - bNum) * dir; } - return String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { numeric: true, sensitivity: 'base' }) * dir; + return ( + String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { + numeric: true, + sensitivity: 'base' + }) * dir + ); }); } diff --git a/src/lib/components/dashboard/dashboard-device-data.spec.ts b/src/lib/components/dashboard/dashboard-device-data.spec.ts index 0475290a..7d60eec6 100644 --- a/src/lib/components/dashboard/dashboard-device-data.spec.ts +++ b/src/lib/components/dashboard/dashboard-device-data.spec.ts @@ -5,7 +5,8 @@ import { applyDashboardLatestReadings, mapDashboardDeviceMetadataToDevice, mapDashboardPrimaryDataToDevice, - mergeDashboardDevices + mergeDashboardDevices, + uniqueDashboardDevices } from './dashboard-device-data'; describe('dashboard-device-data helpers', () => { @@ -313,4 +314,42 @@ describe('dashboard-device-data helpers', () => { soil_humidity: 31.4 }); }); + + it('deduplicates dashboard devices by dev_eui and keeps the newest reading', () => { + const devices = [ + mapDashboardPrimaryDataToDevice({ + dev_eui: 'dev-20', + name: 'Canopy', + location_name: 'Zone A', + group: 'air', + created_at: '2026-03-13T00:05:00.000Z', + co2: 750, + humidity: 47, + temperature_c: 21, + location_id: 42 + }), + mapDashboardPrimaryDataToDevice({ + dev_eui: 'DEV-20', + name: 'Canopy', + location_name: 'Zone A', + group: 'air', + created_at: '2026-03-13T00:00:00.000Z', + co2: 700, + humidity: 45, + temperature_c: 19, + location_id: 42 + }) + ]; + + const uniqueDevices = uniqueDashboardDevices(devices); + + expect(uniqueDevices).toHaveLength(1); + expect(uniqueDevices[0]).toMatchObject({ + dev_eui: 'dev-20', + co2: 750, + humidity: 47, + temperature_c: 21, + created_at: new Date('2026-03-13T00:05:00.000Z') + }); + }); }); diff --git a/src/lib/components/dashboard/dashboard-device-data.ts b/src/lib/components/dashboard/dashboard-device-data.ts index da13bfb7..947e3d75 100644 --- a/src/lib/components/dashboard/dashboard-device-data.ts +++ b/src/lib/components/dashboard/dashboard-device-data.ts @@ -60,6 +60,16 @@ function preferNewerCreatedAt(incoming: Date, current: Date): Date { return incomingMs > currentMs ? incoming : current; } +function isNewerDeviceReading(incoming: IDevice, current: IDevice): boolean { + return preferNewerCreatedAt(incoming.created_at, current.created_at) === incoming.created_at; +} + +function getDashboardDeviceKey(device: IDevice): string { + return String(device.dev_eui ?? '') + .trim() + .toLowerCase(); +} + function getDeviceDataTable( device: DeviceDto | DevicePrimaryDataDto | Record ): string { @@ -259,43 +269,82 @@ export function applyDashboardLatestReadings(target: IDevice, source: IDevice): } } +function mergeDashboardDevice(device: IDevice, latestDevice: IDevice): IDevice { + const resolvedDataTable = preferIncomingText(latestDevice.data_table, device.data_table); + const isSoilDevice = resolvedDataTable === 'cw_soil_data'; + + return { + ...device, + ...latestDevice, + name: preferIncomingText(latestDevice.name, device.name) ?? device.dev_eui, + group: preferIncomingText(latestDevice.group, device.group), + data_table: resolvedDataTable, + location_name: preferIncomingText(latestDevice.location_name, device.location_name) ?? '', + location_id: preferIncomingLocationId(latestDevice.location_id, device.location_id), + has_primary_data: latestDevice.has_primary_data ?? device.has_primary_data, + soil_humidity: + latestDevice.soil_humidity ?? (isSoilDevice ? (device.soil_humidity ?? null) : null), + cwloading: device.cwloading ?? latestDevice.cwloading ?? false, + alert_count: latestDevice.alert_count ?? device.alert_count ?? 0, + device_type_id: device.device_type_id ?? latestDevice.device_type_id, + created_at: preferNewerCreatedAt(latestDevice.created_at, device.created_at), + raw_data: latestDevice.raw_data + ? { ...device.raw_data, ...latestDevice.raw_data } + : device.raw_data + }; +} + +export function uniqueDashboardDevices(devices: IDevice[]): IDevice[] { + const uniqueDevices: IDevice[] = []; + const indexByDevEui = new Map(); + + for (const device of devices) { + const deviceKey = getDashboardDeviceKey(device); + if (!deviceKey) { + uniqueDevices.push(device); + continue; + } + + const existingIndex = indexByDevEui.get(deviceKey); + if (existingIndex == null) { + indexByDevEui.set(deviceKey, uniqueDevices.length); + uniqueDevices.push(device); + continue; + } + + const existingDevice = uniqueDevices[existingIndex]; + uniqueDevices[existingIndex] = isNewerDeviceReading(device, existingDevice) + ? mergeDashboardDevice(existingDevice, device) + : mergeDashboardDevice(device, existingDevice); + } + + return uniqueDevices; +} + export function mergeDashboardDevices( currentDevices: IDevice[], latestDevices: IDevice[] ): IDevice[] { - if (latestDevices.length === 0) return currentDevices; - - const latestByDevEui = new Map(latestDevices.map((device) => [device.dev_eui, device] as const)); - - const mergedDevices = currentDevices.map((device) => { - const latestDevice = latestByDevEui.get(device.dev_eui); - if (!latestDevice) return device; + const mergedDevices = uniqueDashboardDevices(currentDevices); + if (latestDevices.length === 0) return mergedDevices; + + const latestByDevEui = new Map(); + for (const device of uniqueDashboardDevices(latestDevices)) { + const deviceKey = getDashboardDeviceKey(device); + if (deviceKey) { + latestByDevEui.set(deviceKey, device); + } + } - latestByDevEui.delete(device.dev_eui); - const resolvedDataTable = preferIncomingText(latestDevice.data_table, device.data_table); - const isSoilDevice = resolvedDataTable === 'cw_soil_data'; + for (let index = 0; index < mergedDevices.length; index += 1) { + const device = mergedDevices[index]; + const deviceKey = getDashboardDeviceKey(device); + const latestDevice = latestByDevEui.get(deviceKey); + if (!latestDevice) continue; - return { - ...device, - ...latestDevice, - name: preferIncomingText(latestDevice.name, device.name) ?? device.dev_eui, - group: preferIncomingText(latestDevice.group, device.group), - data_table: resolvedDataTable, - location_name: preferIncomingText(latestDevice.location_name, device.location_name) ?? '', - location_id: preferIncomingLocationId(latestDevice.location_id, device.location_id), - has_primary_data: latestDevice.has_primary_data ?? device.has_primary_data ?? false, - soil_humidity: - latestDevice.soil_humidity ?? (isSoilDevice ? (device.soil_humidity ?? null) : null), - cwloading: device.cwloading ?? latestDevice.cwloading ?? false, - alert_count: latestDevice.alert_count ?? device.alert_count ?? 0, - // Preserve device_type_id (metadata device has it, primary data refresh may not) - device_type_id: device.device_type_id ?? latestDevice.device_type_id, - created_at: preferNewerCreatedAt(latestDevice.created_at, device.created_at), - raw_data: latestDevice.raw_data - ? { ...device.raw_data, ...latestDevice.raw_data } - : device.raw_data - }; - }); + latestByDevEui.delete(deviceKey); + mergedDevices[index] = mergeDashboardDevice(device, latestDevice); + } for (const latestDevice of latestByDevEui.values()) { mergedDevices.push({ diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts index 2e83acc4..9c6f6b85 100644 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -2,17 +2,14 @@ import { describe, expect, it, vi } from 'vitest'; import type { LocationDto } from '$lib/api/api.dtos'; import type { IDevice } from '$lib/interfaces/device.interface'; import { m } from '$lib/paraglide/messages.js'; -import { - buildDashboardLocationSensorCards, - buildRelayExpandedDetailRows -} from './device-cards'; +import { buildDashboardLocationSensorCards, buildRelayExpandedDetailRows } from './device-cards'; import { DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES, getDashboardDeviceNextRefreshDelayMs } from './dashboard-device-refresh'; describe('device-cards helpers', () => { - it('groups devices by location, sorts titles, and disambiguates duplicate labels', () => { + it('groups devices by location and keeps simple device labels', () => { const devices: IDevice[] = [ { dev_eui: 'dev-2', @@ -63,17 +60,57 @@ describe('device-cards helpers', () => { const cards = buildDashboardLocationSensorCards(devices, locations, Date.now()); - expect(cards.map((card) => card.title)).toEqual(['Atrium', 'Zone B']); - expect(cards[1]?.sensors.map(({ sensor }) => sensor.label)).toEqual([ - 'Canopy (dev-1)', - 'Canopy (dev-2)' - ]); - expect(cards[1]?.sensors[0]).toMatchObject({ + expect(cards.map((card) => card.title)).toEqual(['Zone B', 'Atrium']); + expect(cards[0]?.sensors.map(({ sensor }) => sensor.label)).toEqual(['Canopy', 'Canopy']); + expect(cards[0]?.sensors[0]).toMatchObject({ + id: 'sensor:dev-2', + storageKey: 'dashboard-device-card:dev-2', + devEui: 'dev-2', + locationId: 2, + sourceDevice: devices[0] + }); + }); + + it('deduplicates repeated device rows so sensor keys stay unique', () => { + const devices: IDevice[] = [ + { + dev_eui: 'dev-1', + name: 'Canopy', + location_name: 'Zone A', + group: 'air', + created_at: new Date('2026-03-13T00:00:00.000Z'), + co2: 880, + humidity: 54, + temperature_c: 21, + location_id: 1 + }, + { + dev_eui: 'dev-1', + name: 'Canopy', + location_name: 'Zone A', + group: 'air', + created_at: new Date('2026-03-13T00:10:00.000Z'), + co2: 901, + humidity: 58, + temperature_c: 23, + location_id: 1 + } + ]; + + const cards = buildDashboardLocationSensorCards( + devices, + [{ location_id: 1, name: 'Zone A' } as LocationDto], + Date.now() + ); + + expect(cards[0]?.sensors).toHaveLength(1); + expect(cards[0]?.sensors[0]).toMatchObject({ id: 'sensor:dev-1', - storageKey: 'dashboard-device-card:dev-1', devEui: 'dev-1', - locationId: 2, - sourceDevice: devices[1] + sensor: { + label: 'Canopy', + primaryValue: 23 + } }); }); @@ -117,17 +154,15 @@ describe('device-cards helpers', () => { ); expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ - label: 'Old Sensor', - status: 'offline' - }); - expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ label: 'Recent Sensor', status: 'online' }); - expect(cards[0]?.sensors[1]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); - expect( - getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[0]!.sourceDevice) - ).not.toBeNull(); + expect(cards[0]?.sensors[0]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); + expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ + label: 'Old Sensor', + status: 'offline' + }); + expect(getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[1]!.sourceDevice)).not.toBeNull(); expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3); vi.useRealTimers(); @@ -189,7 +224,9 @@ describe('device-cards helpers', () => { ); const sensors = cards[0]!.sensors.map((entry) => entry.sensor); - const [greenhouse, idle, pump] = sensors; + const greenhouse = sensors.find((sensor) => sensor.label === 'Greenhouse Relay'); + const pump = sensors.find((sensor) => sensor.label === 'Pump Relay'); + const idle = sensors.find((sensor) => sensor.label === 'Idle Relay'); expect(greenhouse).toMatchObject({ label: 'Greenhouse Relay', @@ -201,8 +238,8 @@ describe('device-cards helpers', () => { secondary_icon: 'relay', status: 'online' }); - expect(greenhouse.primaryUnit).toBe(m.display_relay_one()); - expect(greenhouse.secondaryUnit).toBe(m.display_relay_two()); + expect(greenhouse?.primaryUnit).toBe(m.display_relay_one()); + expect(greenhouse?.secondaryUnit).toBe(m.display_relay_two()); expect(pump).toMatchObject({ primaryValue: 1, diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index 4e7cde42..9cf18e08 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -6,6 +6,7 @@ import type { IDevice } from '$lib/interfaces/device.interface'; import { m } from '$lib/paraglide/messages.js'; import { resolveDeviceTypeConfig, + uniqueDashboardDevices, type DeviceTypeConfig, type DeviceTypeLookup } from './dashboard-device-data'; @@ -33,21 +34,26 @@ export interface DashboardLocationSensorCard { sensors: DashboardSensorCardEntry[]; } -// function getDeviceBaseLabel(device: IDevice): string { -// return device.name.trim() || device.dev_eui; -// } - -function getDeviceLabel(device: IDevice, duplicateCounts: Map): string { - const baseLabel = device.name; - return (duplicateCounts.get(baseLabel) ?? 0) > 1 ? `${baseLabel} (${device.dev_eui})` : baseLabel; +function getDeviceLabel(device: IDevice): string { + return device.name?.trim() || device.dev_eui?.trim() || 'Unnamed device'; } function getDeviceStatus(device: IDevice, nowMs: number): 'online' | 'offline' { return isDashboardDeviceOffline(device, nowMs) ? 'offline' : 'online'; } -function getSensorStorageKey(device: IDevice): string { - return `dashboard-device-card:${device.dev_eui}`; +function getDeviceLocationId(device: IDevice): number { + const locationId = Number(device.location_id); + return Number.isFinite(locationId) ? locationId : 0; +} + +function getSensorEntryId(device: IDevice, fallbackId: string): string { + const devEui = device.dev_eui?.trim(); + return devEui ? `sensor:${devEui}` : fallbackId; +} + +function getSensorStorageKey(device: IDevice, entryId: string): string { + return `dashboard-device-card:${device.dev_eui?.trim() || entryId}`; } function getRelayStateLabel(value: boolean | null): string { @@ -141,7 +147,7 @@ function getLocationTitle( } const deviceLocationName = locationDevices.find((device) => - device.location_name.trim() + device.location_name?.trim() )?.location_name; if (deviceLocationName) { return deviceLocationName.trim(); @@ -268,17 +274,18 @@ export function buildDeviceExpandedDetailRows( function buildRelaySensorCardEntry( device: IDevice, label: string, - nowMs: number + nowMs: number, + entryId: string ): DashboardSensorCardEntry { const relay1 = coerceRelayValue(device.raw_data?.relay_1); const relay2 = coerceRelayValue(device.raw_data?.relay_2); const relayToValue = (value: boolean | null): number => (value === true ? 1 : 0); return { - id: `sensor:${device.dev_eui}`, - storageKey: getSensorStorageKey(device), + id: entryId, + storageKey: getSensorStorageKey(device, entryId), devEui: device.dev_eui, - locationId: Number(device.location_id), + locationId: getDeviceLocationId(device), sourceDevice: device, sensor: { label, @@ -302,24 +309,24 @@ function buildRelaySensorCardEntry( function buildDashboardSensorCardEntry( device: IDevice, - duplicateCounts: Map, nowMs: number, - deviceTypeLookup?: DeviceTypeLookup + deviceTypeLookup: DeviceTypeLookup | undefined, + entryId: string ): DashboardSensorCardEntry { - const label = getDeviceLabel(device, duplicateCounts); + const label = getDeviceLabel(device); if (isRelayDevice(device)) { - return buildRelaySensorCardEntry(device, label, nowMs); + return buildRelaySensorCardEntry(device, label, nowMs, entryId); } const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); if (device.has_primary_data === false) { return { - id: `sensor:${device.dev_eui}`, - storageKey: getSensorStorageKey(device), + id: entryId, + storageKey: getSensorStorageKey(device, entryId), devEui: device.dev_eui, - locationId: Number(device.location_id), + locationId: getDeviceLocationId(device), sourceDevice: device, sensor: { label, @@ -342,10 +349,10 @@ function buildDashboardSensorCardEntry( null; return { - id: `sensor:${device.dev_eui}`, - storageKey: getSensorStorageKey(device), + id: entryId, + storageKey: getSensorStorageKey(device, entryId), devEui: device.dev_eui, - locationId: Number(device.location_id), + locationId: getDeviceLocationId(device), sourceDevice: device, sensor: { label, @@ -367,13 +374,18 @@ export function buildDashboardLocationSensorCards( nowMs: number, deviceTypeLookup?: DeviceTypeLookup ): DashboardLocationSensorCard[] { - const locationsById = new Map( - locations.map((location) => [Number(location.location_id), location] as const) - ); + const locationsById = new Map(); + for (const location of locations) { + const locationId = Number(location.location_id); + if (Number.isFinite(locationId)) { + locationsById.set(locationId, location); + } + } + const devicesByLocationId = new Map(); - for (const device of devices) { - const locationId = Number(device.location_id); + for (const device of uniqueDashboardDevices(devices)) { + const locationId = getDeviceLocationId(device); const locationDevices = devicesByLocationId.get(locationId); if (locationDevices) { @@ -384,36 +396,13 @@ export function buildDashboardLocationSensorCards( devicesByLocationId.set(locationId, [device]); } - return Array.from(devicesByLocationId.entries()) - .map(([locationId, locationDevices]) => { - const sortedLocationDevices = [...locationDevices].sort( - (left, right) => - left.name.localeCompare(right.name, undefined, { - numeric: true, - sensitivity: 'base' - }) || - left.dev_eui.localeCompare(right.dev_eui, undefined, { - numeric: true, - sensitivity: 'base' - }) - ); - const duplicateCounts = new Map(); - - for (const device of sortedLocationDevices) { - const baseLabel = device.name; - duplicateCounts.set(baseLabel, (duplicateCounts.get(baseLabel) ?? 0) + 1); - } - - return { - id: `location:${locationId}`, - locationId, - title: getLocationTitle(locationId, locationsById, sortedLocationDevices), - sensors: sortedLocationDevices.map((device) => - buildDashboardSensorCardEntry(device, duplicateCounts, nowMs, deviceTypeLookup) - ) - } satisfies DashboardLocationSensorCard; + return Array.from(devicesByLocationId.entries()).map(([locationId, locationDevices]) => ({ + id: `location:${locationId}`, + locationId, + title: getLocationTitle(locationId, locationsById, locationDevices), + sensors: locationDevices.map((device, index) => { + const entryId = getSensorEntryId(device, `sensor:${locationId}:${index}`); + return buildDashboardSensorCardEntry(device, nowMs, deviceTypeLookup, entryId); }) - .sort((left, right) => - left.title.localeCompare(right.title, undefined, { numeric: true, sensitivity: 'base' }) - ); + })); } diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.svelte b/src/lib/components/displays/AirDisplay/AirDisplay.svelte index f4e3737e..d493522f 100644 --- a/src/lib/components/displays/AirDisplay/AirDisplay.svelte +++ b/src/lib/components/displays/AirDisplay/AirDisplay.svelte @@ -14,8 +14,6 @@ type CwTableQuery, type CwTableResult } from '@cropwatchdevelopment/cwui'; - import { ApiService } from '$lib/api/api.service'; - import { formatDateTime } from '$lib/i18n/format'; import type { DeviceDisplayProps } from '$lib/interfaces/deviceDisplay'; import { m } from '$lib/paraglide/messages.js'; import './AirDisplay.css'; @@ -38,7 +36,7 @@ { key: 'alerts', header: m.status_alerts(), width: '3rem' } ]; - const ALARM_AFTER_MINUTES = 10.5; + const ALARM_AFTER_MINUTES = 10.2; let { latestData, historicalData, loading, devEui, authToken }: DeviceDisplayProps = $props(); let noteOverridesByDevice = $state>>({}); diff --git a/src/lib/components/rule-actions/Relay-Actions.svelte b/src/lib/components/rule-actions/Relay-Actions.svelte index c06520cd..4817c07b 100644 --- a/src/lib/components/rule-actions/Relay-Actions.svelte +++ b/src/lib/components/rule-actions/Relay-Actions.svelte @@ -1,5 +1,6 @@