diff --git a/messages/en.json b/messages/en.json index eb8285c3..24fe3d9f 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", @@ -430,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", @@ -791,6 +857,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", @@ -911,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 d5786ea2..1c3904b9 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": "対象デバイス", @@ -430,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": "システム項目", @@ -791,6 +857,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": "検索・並べ替え可能なデータ", @@ -911,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 cffd1312..5e8b7130 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.91", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3495eaba..a7c74731 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.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.84': - resolution: {integrity: sha512-aUKXyn0JaNZITCrr1h3waBIKGvIR16g5L+0GwS1PJyzGpzUdIstu3NB5er9H8twFg2drLj2eNc8n/aOrbOlEHg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.84/028c5318b58155ec7cd7cb09cfeed35f8ba47bbe} + '@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.84(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.91(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..3275eb6e 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -185,6 +185,95 @@ 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; + 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 09ebeeba..e611ca90 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 @@ -361,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); @@ -458,16 +471,13 @@ // 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; - expandedDetailRowsByDevEui[devEui] = buildDeviceExpandedDetailRows( - { ...freshData, created_at: createdAtForRow }, - typeConfig, - sensor.sensor.label - ); + 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 + ? buildRelayExpandedDetailRows(dataForRows, sensor.sensor.label) + : buildDeviceExpandedDetailRows(dataForRows, typeConfig, sensor.sensor.label); } function handleSensorCollapse(sensor: DashboardSensorCardEntry) { @@ -504,22 +514,23 @@ 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 .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); @@ -544,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}
@@ -663,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; } @@ -703,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/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 cca2724b..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', () => { @@ -288,7 +289,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,8 +311,45 @@ 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 }); }); + + 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 8b1cb135..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 { @@ -67,7 +77,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 +90,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 +117,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 +160,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 +212,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; } } @@ -221,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 dd275465..9c6f6b85 100644 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -1,14 +1,15 @@ 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 } 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', @@ -59,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 + } }); }); @@ -113,20 +154,143 @@ describe('device-cards helpers', () => { ); expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ + label: 'Recent Sensor', + status: 'online' + }); + expect(cards[0]?.sensors[0]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); + expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ label: 'Old Sensor', status: 'offline' }); - expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ - label: 'Recent Sensor', + expect(getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[1]!.sourceDevice)).not.toBeNull(); + expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3); + + 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 = 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', + 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(cards[0]?.sensors[1]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); + 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( - getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[0]!.sourceDevice) - ).not.toBeNull(); - expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3); + buildRelayExpandedDetailRows({ relay_1: true, relay_2: true, created_at: 'nope' }, 'X') + ).toHaveLength(2); + }); - vi.useRealTimers(); + 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', () => { diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index 88864138..9cf18e08 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -1,8 +1,12 @@ 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, + uniqueDashboardDevices, type DeviceTypeConfig, type DeviceTypeLookup } from './dashboard-device-data'; @@ -30,25 +34,84 @@ export interface DashboardLocationSensorCard { sensors: DashboardSensorCardEntry[]; } -function getDeviceBaseLabel(device: IDevice): string { - return device.name.trim() || device.dev_eui; +function getDeviceLabel(device: IDevice): string { + return device.name?.trim() || device.dev_eui?.trim() || 'Unnamed device'; } -function getDeviceLabel(device: IDevice, duplicateCounts: Map): string { - const baseLabel = getDeviceBaseLabel(device); - 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'; } +function getDeviceLocationId(device: IDevice): number { + const locationId = Number(device.location_id); + return Number.isFinite(locationId) ? locationId : 0; +} -function getDeviceStatus(device: IDevice, nowMs: number): 'online' | 'offline' { - return isDashboardDeviceOffline(device, nowMs) ? 'offline' : 'online'; +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 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[] { +function buildUnavailableDetailRows( + label: string, + typeConfig: DeviceTypeConfig | undefined +): CwSensorCardDetailRow[] { const primaryLabel = typeConfig?.primary_data_key ?? 'Temperature'; const secondaryLabel = typeConfig?.secondary_data_key ?? 'Humidity'; return [ @@ -84,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(); @@ -95,26 +158,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 { @@ -194,21 +271,62 @@ export function buildDeviceExpandedDetailRows( return rows; } +function buildRelaySensorCardEntry( + device: IDevice, + label: string, + 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: entryId, + storageKey: getSensorStorageKey(device, entryId), + devEui: device.dev_eui, + locationId: getDeviceLocationId(device), + 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, 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, 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, @@ -220,15 +338,21 @@ 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; - console.log(device); 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, @@ -236,7 +360,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 @@ -250,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) { @@ -267,36 +396,13 @@ export function buildDashboardLocationSensorCards( devicesByLocationId.set(locationId, [device]); } - return Array.from(devicesByLocationId.entries()) - .map(([locationId, locationDevices]) => { - const sortedLocationDevices = [...locationDevices].sort( - (left, right) => - getDeviceBaseLabel(left).localeCompare(getDeviceBaseLabel(right), 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 = getDeviceBaseLabel(device); - 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/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte index 0cba639e..fe573cbc 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; @@ -51,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/components/rule-actions/Relay-Actions.svelte b/src/lib/components/rule-actions/Relay-Actions.svelte new file mode 100644 index 00000000..4a8ebe5c --- /dev/null +++ b/src/lib/components/rule-actions/Relay-Actions.svelte @@ -0,0 +1,263 @@ + + +
+ + + + + {#if isTimedAction} + + {/if} + + + +
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; 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/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-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..6d166f6e --- /dev/null +++ b/src/lib/rules-new/rule-template.types.ts @@ -0,0 +1,13 @@ +export type { + Json, + RuleActionTypeDto, + RuleTemplateActionDto, + RuleTemplateActionInput, + RuleTemplateAssignmentDto, + RuleTemplateCriterionDto, + RuleTemplateCriterionInput, + RuleTemplateDto, + RuleTemplateListQuery, + RuleTemplateSaveRequest, + RuleTemplateStateDto +} from '$lib/api/api.dtos'; 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..e0458b08 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(); @@ -175,9 +127,6 @@
{@render children()}
-
- -
{:else}
{@render children()}
@@ -192,7 +141,7 @@
\ No newline at end of file + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 84a0a4e6..9972115b 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,12 +1,10 @@ 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 + uniqueDashboardDevices } from '$lib/components/dashboard/dashboard-device-data'; import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashboard-filter-values'; import type { PageServerLoad } from './$types'; @@ -18,28 +16,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 +51,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[] = uniqueDashboardDevices( + 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() + })) + ); [] = $derived.by(() => { - const allItem: CwListBoxItem = { - value: '', - label: m.sidebar_all_groups(), - badge: m.common_all_short(), - badgeTone: 'secondary', - endText: String(app.devices?.length ?? 0) - }; - const groupItems: CwListBoxItem[] = normalizeDashboardFilterValues( - app.deviceGroups - ).map((group) => ({ - value: group, - label: group, - badge: group.toUpperCase().substring(0, 2), - badgeTone: 'info' as const - // endText: String(group.length ?? 0) - })); - return [allItem, ...groupItems]; - }); + // const groups: CwListBoxItem[] = $derived.by(() => { + // const allItem: CwListBoxItem = { + // value: '', + // label: m.sidebar_all_groups(), + // badge: m.common_all_short(), + // badgeTone: 'secondary', + // endText: String(app.devices?.length ?? 0) + // }; + // const groupItems: CwListBoxItem[] = normalizeDashboardFilterValues( + // app.deviceGroups + // ).map((group) => ({ + // value: group, + // label: group, + // badge: group.toUpperCase().substring(0, 2), + // badgeTone: 'info' as const + // // endText: String(group.length ?? 0) + // })); + // return [allItem, ...groupItems]; + // }); // ── Location Groups list (dynamic from API) ────────────────────────── const locationGroups: CwListBoxItem[] = $derived.by(() => { @@ -115,22 +122,27 @@ }); // ── Locations list (dynamic from API) ─────────────────────── - const locations: CwListBoxItem[] = $derived.by(() => { - const allItem: CwListBoxItem = { - value: '', - label: m.sidebar_all_locations(), - endText: String(app.devices?.length ?? 0) - }; - const locationItems: CwListBoxItem[] = (app.locations ?? []).map((loc) => ({ - value: String(loc.location_id), - label: loc.name, - endText: String(app.devices?.filter((d) => d.location_id === loc.location_id).length ?? 0) - })); - return [allItem, ...locationItems]; - }); + // const locations: CwListBoxItem[] = $derived.by(() => { + // const allItem: CwListBoxItem = { + // value: '', + // label: m.sidebar_all_locations(), + // endText: String(app.devices?.length ?? 0) + // }; + // const locationItems: CwListBoxItem[] = (app.locations ?? []).map((loc) => ({ + // value: String(loc.location_id), + // label: loc.name, + // endText: String(app.devices?.filter((d) => d.location_id === loc.location_id).length ?? 0) + // })); + // return [allItem, ...locationItems]; + // }); // ── Navigate with updated search params on filter change ──── function applyFilter(key: string, value: string) { + // Close Sidebar + if (window.innerWidth < 1024) { + mode = 'closed'; + } + const params = new URL(page.url); if (value) { params.searchParams.set(key, value); @@ -169,12 +181,12 @@
- applyFilter('group', item.value)} - /> + /> --> applyFilter('locationGroup', item.value)} /> - applyFilter('location', item.value)} - /> + /> -->
{/if} 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 deleted file mode 100644 index 2e961fa4..00000000 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - (open = true)}> - - Notes - \ No newline at end of file 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]/NotesReviewDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte similarity index 100% rename from src/routes/locations/[location_id]/devices/[dev_eui]/NotesReviewDialog.svelte rename to src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/ViewNoteHistoryDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/ViewNoteHistoryDialog.svelte new file mode 100644 index 00000000..ceccbee7 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/ViewNoteHistoryDialog.svelte @@ -0,0 +1,12 @@ + + + (open = true)}> + + Notes + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte similarity index 99% rename from src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte rename to src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte index 63a1ac3f..b9be9879 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte @@ -14,7 +14,7 @@ formatCsvRangeLabel, resolveCsvRequestRange, type CsvRow - } from './csvExport'; + } from '../csvExport'; import { m } from '$lib/paraglide/messages.js'; interface Props { diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte similarity index 98% rename from src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte rename to src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte index aae07e09..d2768e45 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte @@ -4,7 +4,7 @@ import type { TrafficMonthlyReportDto } from '$lib/api/api.dtos'; import DOWNLOAD_ICON from '$lib/images/icons/download.svg'; import { CwButton, CwDialog, CwInput, useCwToast } from '@cropwatchdevelopment/cwui'; - import { downloadCsv } from './csvExport'; + import { downloadCsv } from '../csvExport'; import { m } from '$lib/paraglide/messages.js'; import NO_ICON from '$lib/images/icons/no.svg'; 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.svelte b/src/routes/rules-new/+page.svelte new file mode 100644 index 00000000..7560de0d --- /dev/null +++ b/src/routes/rules-new/+page.svelte @@ -0,0 +1,229 @@ + + + + {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'))}> + + + {/snippet} +
+ {/key} +
+
+ + diff --git a/src/routes/rules-new/DeleteRuleTemplateDialog.svelte b/src/routes/rules-new/DeleteRuleTemplateDialog.svelte new file mode 100644 index 00000000..e9c834d2 --- /dev/null +++ b/src/routes/rules-new/DeleteRuleTemplateDialog.svelte @@ -0,0 +1,88 @@ + + + (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..d1efe149 --- /dev/null +++ b/src/routes/rules-new/RuleTemplateForm.svelte @@ -0,0 +1,598 @@ + + + + + + + + + + + + + {#if deviceOptions.length === 0} + +

{m.rules_no_devices_available()}

+
+ {:else} +
+ +
+ {/if} +
+
+ + + + {#each criteriaGroups as group, index (group.localId)} +
+
+ {m.rules_condition_number({ count: String(index + 1) })} + {#if criteriaGroups.length > 1} + criteriaGroups.splice(index, 1)}> + {m.action_remove()} + + {/if} +
+ + + (group.alertPoints = ensureAlertPointsIncludeReset(value))} + text={alertPointsEditorText} + /> +
+ {/each} + + {#if hasUnsupportedCriteria} + +

{m.rules_new_test_reason_unsupported_operator()}

+
+ {/if} + + { + criteriaGroups.push(createBlankAlertCriteriaGroup(nextCriteriaGroupId)); + nextCriteriaGroupId += 1; + }} + > + {m.rules_add_another_condition()} + +
+
+ + + + {#each templateActions as action, index (action)} +
+
+ {m.rules_new_action_number({ count: String(index + 1) })} + {#if templateActions.length > 1} + templateActions.splice(index, 1)}> + {m.action_remove()} + + {/if} +
+ +
+ selectActionType(action, value)} + required + /> + {#if action.actionTypeName === 'EMail'} + + {:else if action.actionTypeName === 'LoRaWAN'} + + {/if} +
+
+ {/each} + + { + templateActions.push(createBlankTemplateAction()); + }} + > + {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()}:
+
{assignmentSummary}
+
{m.rules_conditions()}:
+
{criteriaSummary}
+
{m.rules_new_actions()}:
+
{actionSummary}
+
+
+ {: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..094ea80e --- /dev/null +++ b/src/routes/rules-new/RuleTemplateTest.svelte @@ -0,0 +1,239 @@ + + + + + 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..d813bbc0 --- /dev/null +++ b/src/routes/rules-new/create/+page.server.ts @@ -0,0 +1,19 @@ +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: [], actionTypes: [], authToken, devEui }; + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + const [devices, actionTypes] = await Promise.all([ + api.getAllDevices().catch(() => []), + api.getRuleTemplateActionTypes().catch(() => []) + ]); + + return { devices, actionTypes, authToken, 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..401b4fbc --- /dev/null +++ b/src/routes/rules-new/create/+page.svelte @@ -0,0 +1,44 @@ + + + + {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..6b087c8f --- /dev/null +++ b/src/routes/rules-new/edit/[id]/+page.server.ts @@ -0,0 +1,38 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +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 api.getRuleTemplate(templateId); + } catch (loadError) { + if (loadError instanceof ApiServiceError && 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, actionTypes] = await Promise.all([ + api.getAllDevices().catch(() => []), + api.getRuleTemplateActionTypes().catch(() => []) + ]); + + return { template, devices, actionTypes, authToken }; +}; 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..bd6650be --- /dev/null +++ b/src/routes/rules-new/edit/[id]/+page.svelte @@ -0,0 +1,59 @@ + + + + {m.rules_new_edit_page_title()} + + + + goto(resolve('/rules-new'))}> + ← {m.action_back()} + + +
+

{m.rules_new_edit_template()}

+ +
+ + +
+ + diff --git a/src/routes/rules-new/rule-template-alert-points.spec.ts b/src/routes/rules-new/rule-template-alert-points.spec.ts new file mode 100644 index 00000000..1b48baed --- /dev/null +++ b/src/routes/rules-new/rule-template-alert-points.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui'; +import { + areAlertCriteriaGroupsValid, + buildInitialAlertCriteriaGroups, + buildRuleTemplateCriteriaFromAlertGroups, + createBlankAlertCriteriaGroup, + ensureAlertPointsIncludeReset, + hasUnsupportedAlertPointConditions +} from './rule-template-alert-points'; +import type { RuleTemplateCriterionDto } from '$lib/rules-new/rule-template.types'; + +describe('rule template alert points adapter', () => { + it('creates blank alert points with reset included', () => { + const group = createBlankAlertCriteriaGroup(1); + + expect(group.alertPoints.points[0]?.reset).toBe(''); + }); + + it('adds reset to editor values that do not include it', () => { + const value: CwAlertPointsValue = { + unit: 'C', + center: '0', + points: [ + { + id: 'no-reset', + name: 'High temperature', + color: '#e35c8d', + condition: 'greaterThan', + value: '30', + min: '', + max: '' + } + ] + }; + + expect(ensureAlertPointsIncludeReset(value).points[0]?.reset).toBe(''); + }); + + it('maps alert points into rule template criteria payloads', () => { + const criteria = buildRuleTemplateCriteriaFromAlertGroups([ + { + localId: 1, + subject: 'temperature_c', + alertPoints: { + unit: 'C', + center: '0', + points: [ + { + id: 'criterion-12', + name: 'High temperature', + color: '#e35c8d', + condition: 'greaterThanOrEqual', + value: '30', + min: '', + max: '', + reset: '25' + } + ] + } + } + ]); + + expect(criteria).toEqual([ + { + id: 12, + subject: 'temperature_c', + operator: '>=', + triggerValue: 30, + resetValue: 25 + } + ]); + }); + + it('blocks range alert points because rule templates require one trigger and reset value', () => { + const group = createBlankAlertCriteriaGroup(1); + group.alertPoints.points[0] = { + ...group.alertPoints.points[0]!, + condition: 'range', + min: '10', + max: '20', + reset: '' + }; + + expect(hasUnsupportedAlertPointConditions([group])).toBe(true); + expect(areAlertCriteriaGroupsValid([group])).toBe(false); + }); + + it('keeps existing range operators unsupported instead of changing their meaning', () => { + const groups = buildInitialAlertCriteriaGroups([ + criterion({ + operator: 'range', + triggerValue: 10, + resetValue: 0 + }) + ]); + + expect(groups[0]?.alertPoints.points[0]?.condition).toBe('range'); + expect(hasUnsupportedAlertPointConditions(groups)).toBe(true); + }); +}); + +function criterion(overrides: Partial): RuleTemplateCriterionDto { + return { + id: overrides.id ?? 1, + templateId: overrides.templateId ?? 1, + subject: overrides.subject ?? 'temperature_c', + operator: overrides.operator ?? '>', + triggerValue: overrides.triggerValue ?? 30, + resetValue: overrides.resetValue ?? 25, + createdAt: overrides.createdAt ?? null + }; +} diff --git a/src/routes/rules-new/rule-template-alert-points.ts b/src/routes/rules-new/rule-template-alert-points.ts new file mode 100644 index 00000000..82499790 --- /dev/null +++ b/src/routes/rules-new/rule-template-alert-points.ts @@ -0,0 +1,279 @@ +import { getUiLocale } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; +import type { + CwAlertPointCondition, + CwAlertPointRule, + CwAlertPointsEditorText, + CwAlertPointsValue +} from '@cropwatchdevelopment/cwui'; +import type { + RuleTemplateCriterionDto, + RuleTemplateCriterionInput +} from '$lib/rules-new/rule-template.types'; + +export interface RuleTemplateAlertCriteriaGroup { + localId: number; + subject: string; + alertPoints: CwAlertPointsValue; +} + +const DEFAULT_RULE_SUBJECT = 'temperature_c'; +const DEFAULT_ALERT_CENTER = '0'; +const ALERT_POINT_COLORS = ['#f7903b', '#42edf0', '#7a8cff', '#e35c8d', '#4fcf7a', '#ffcf5a']; + +function formatOverlapLabels(labels: string[]): string { + return labels.join(getUiLocale() === 'ja' ? '、' : ', '); +} + +export function createRuleTemplateAlertPointsEditorText(): CwAlertPointsEditorText { + return { + unitFieldLabel: m.reports_create_alert_points_unit_label(), + centerFieldLabel: m.reports_create_alert_points_center_label(), + nameFieldLabel: m.common_name(), + conditionFieldLabel: m.rules_operator(), + valueFieldLabel: m.rules_trigger_value(), + minValueFieldLabel: m.reports_create_alert_minimum(), + maxValueFieldLabel: m.reports_create_alert_maximum(), + resetFieldLabel: m.rules_reset_value(), + colorFieldLabel: m.reports_create_alert_hex_color(), + addAlertPointButton: m.rules_add_another_condition(), + removePointButton: m.action_remove(), + emptyTitle: m.reports_create_alert_points_empty_title(), + emptyDescription: m.reports_create_alert_points_empty_description(), + invalidNumberError: m.reports_create_alert_points_invalid_number(), + requiredFieldError: (label) => m.reports_create_alert_points_required_field({ label }), + fieldLabelWithUnit: (label, unit) => + m.reports_create_alert_points_label_with_unit({ label, unit }), + invalidPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_invalid_preview_single() + : m.reports_create_alert_points_invalid_preview_multiple({ count: String(count) }), + overlapPreviewNote: (count) => + 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', + unitFahrenheitLabel: '°F', + unitKelvinLabel: '°K', + conditionEqualsLabel: m.reports_create_alert_points_condition_equals(), + conditionRangeLabel: m.reports_create_alert_points_condition_range(), + conditionLessThanLabel: m.reports_create_alert_points_condition_less_than(), + conditionLessThanOrEqualLabel: m.reports_create_alert_points_condition_less_than_or_equal(), + conditionGreaterThanLabel: m.reports_create_alert_points_condition_greater_than(), + conditionGreaterThanOrEqualLabel: + m.reports_create_alert_points_condition_greater_than_or_equal(), + pointDescriptionWaitingForValue: m.reports_create_alert_points_waiting_for_value(), + pointDescriptionWaitingForThreshold: m.reports_create_alert_points_waiting_for_threshold(), + pointDescriptionRangeMissingBounds: m.reports_create_alert_points_range_missing_bounds(), + pointDescriptionEquals: (value, unit) => + m.reports_create_alert_points_description_equals({ value, unit }), + pointDescriptionRange: (min, max, unit) => + m.reports_create_alert_points_description_range({ min, max, unit }), + pointDescriptionLessThan: (value, unit) => + m.reports_create_alert_points_description_less_than({ value, unit }), + pointDescriptionLessThanOrEqual: (value, unit) => + m.reports_create_alert_points_description_less_than_or_equal({ value, unit }), + pointDescriptionGreaterThan: (value, unit) => + 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) }) + }; +} + +export function buildInitialAlertCriteriaGroups( + criteria: RuleTemplateCriterionDto[] = [] +): RuleTemplateAlertCriteriaGroup[] { + if (criteria.length === 0) { + return [createBlankAlertCriteriaGroup(1)]; + } + + const criteriaBySubject = new Map(); + for (const criterion of criteria) { + const subject = criterion.subject.trim() || DEFAULT_RULE_SUBJECT; + criteriaBySubject.set(subject, [...(criteriaBySubject.get(subject) ?? []), criterion]); + } + + return [...criteriaBySubject.entries()].map(([subject, groupCriteria], index) => ({ + localId: index + 1, + subject, + alertPoints: { + unit: 'C', + center: DEFAULT_ALERT_CENTER, + points: groupCriteria.map(mapCriterionToAlertPoint) + } + })); +} + +export function createBlankAlertCriteriaGroup(localId: number): RuleTemplateAlertCriteriaGroup { + return { + localId, + subject: DEFAULT_RULE_SUBJECT, + alertPoints: { + unit: 'C', + center: DEFAULT_ALERT_CENTER, + points: [createBlankAlertPoint(1)] + } + }; +} + +export function buildRuleTemplateCriteriaFromAlertGroups( + groups: RuleTemplateAlertCriteriaGroup[] +): RuleTemplateCriterionInput[] { + return groups.flatMap((group) => + group.alertPoints.points.map((point) => ({ + id: readPersistedCriterionId(point.id), + subject: group.subject, + operator: mapAlertConditionToOperator(point.condition), + triggerValue: readAlertPointTriggerValue(point), + resetValue: readNumberOrZero(point.reset ?? '') + })) + ); +} + +export function areAlertCriteriaGroupsValid(groups: RuleTemplateAlertCriteriaGroup[]): boolean { + return ( + groups.length > 0 && + groups.every( + (group) => + group.subject.trim().length > 0 && + group.alertPoints.points.length > 0 && + group.alertPoints.points.every(isAlertPointValid) + ) + ); +} + +export function hasUnsupportedAlertPointConditions( + groups: RuleTemplateAlertCriteriaGroup[] +): boolean { + return groups.some((group) => + group.alertPoints.points.some((point) => point.condition === 'range') + ); +} + +function createBlankAlertPoint(index: number): CwAlertPointRule { + return { + id: `rule-alert-point-${index}`, + name: m.rules_condition_number({ count: String(index) }), + color: ALERT_POINT_COLORS[(index - 1) % ALERT_POINT_COLORS.length], + condition: 'greaterThan', + value: '', + min: '', + max: '', + reset: '' + }; +} + +function mapCriterionToAlertPoint( + criterion: RuleTemplateCriterionDto, + index: number +): CwAlertPointRule { + return { + id: `criterion-${criterion.id}`, + name: m.rules_condition_number({ count: String(index + 1) }), + color: ALERT_POINT_COLORS[index % ALERT_POINT_COLORS.length], + condition: mapOperatorToAlertCondition(criterion.operator), + value: String(criterion.triggerValue), + min: '', + max: '', + reset: String(criterion.resetValue) + }; +} + +export function ensureAlertPointsIncludeReset(value: CwAlertPointsValue): CwAlertPointsValue { + let changed = false; + const points = value.points.map((point) => { + if (point.reset !== undefined) return point; + changed = true; + return { + ...point, + reset: '' + }; + }); + + return changed ? { ...value, points } : value; +} + +function mapOperatorToAlertCondition(operator: string): CwAlertPointCondition { + switch (operator.trim()) { + case '>': + return 'greaterThan'; + case '>=': + return 'greaterThanOrEqual'; + case '<': + return 'lessThan'; + case '<=': + return 'lessThanOrEqual'; + case 'range': + return 'range'; + case '=': + default: + return 'equals'; + } +} + +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 readAlertPointTriggerValue(point: CwAlertPointRule): number { + return point.condition === 'range' ? readNumberOrZero(point.min) : readNumberOrZero(point.value); +} + +function isAlertPointValid(point: CwAlertPointRule): boolean { + if (point.condition === 'range') return false; + return isFiniteNumberText(point.value) && isFiniteNumberText(point.reset ?? ''); +} + +function isFiniteNumberText(value: string): boolean { + if (!value.trim()) return false; + return Number.isFinite(Number(value)); +} + +function readNumberOrZero(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function readPersistedCriterionId(id: string): number | null { + const match = /^criterion-(\d+)$/.exec(id); + if (!match) return null; + + const parsed = Number(match[1]); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/src/routes/rules/RuleForm.svelte b/src/routes/rules/RuleForm.svelte new file mode 100644 index 00000000..eeb88c86 --- /dev/null +++ b/src/routes/rules/RuleForm.svelte @@ -0,0 +1,442 @@ + + + + {#if mode === 'create'} + goto(resolve('/rules'))}> + ← {m.action_back()} + + {/if} + +
+
+ {#if isEdit} + goto(resolve('/rules'))}> + {m.action_back()} + + {/if} +

+ {isEdit ? m.rules_edit_rule() : m.rules_create_new_rule()} +

+ {#if initialRule} + + {/if} +
+ + + + + +
+ {#if isEdit} + + + {:else} + + + {/if} +
+ + + {#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} +
+ +
+ + + +
+ +
+
+ + {#if isEdit} +

+ {m.rules_reset_value_help()} +

+ {/if} +
+
+
+ {/each} + + {#if mode === 'create'} + + {m.rules_add_another_condition()} + + {/if} +
+
+ + + + {#if isFormValid} + +
+
{m.common_name()}:
+
{ruleName}
+
{m.devices_device()}:
+
{selectedDeviceName}
+
{m.rules_notify_via()}:
+
+ {NOTIFIER_TYPES.find((notifier) => notifier.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()} + + + {#if isEdit} + + {submitting ? m.action_saving() : m.action_save_changes()} + {:else} + {submitting ? m.rules_creating() : m.rules_create_rule()} + {/if} + + +
+
+
+
+ + 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()} - - -
-
-
-
- - +